af-filesystem-mcp 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- af_filesystem_mcp-0.1.1/.github/dependabot.yml +26 -0
- af_filesystem_mcp-0.1.1/.github/release.yml +5 -0
- af_filesystem_mcp-0.1.1/.github/workflows/cd.yml +61 -0
- af_filesystem_mcp-0.1.1/.github/workflows/ci.yml +104 -0
- af_filesystem_mcp-0.1.1/.gitignore +52 -0
- af_filesystem_mcp-0.1.1/.pre-commit-config.yaml +67 -0
- af_filesystem_mcp-0.1.1/CLAUDE.md +279 -0
- af_filesystem_mcp-0.1.1/LICENSE +202 -0
- af_filesystem_mcp-0.1.1/PKG-INFO +115 -0
- af_filesystem_mcp-0.1.1/README.md +88 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/.helmignore +11 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/Chart.yaml +23 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/README.md +34 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/ci/broker-values.yaml +10 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/NOTES.txt +39 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/_helpers.tpl +114 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/configmap.yaml +23 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/deployment.yaml +161 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/ingress.yaml +36 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/service.yaml +15 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/serviceaccount.yaml +12 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/templates/tests/test-healthz.yaml +18 -0
- af_filesystem_mcp-0.1.1/charts/af-filesystem-mcp/values.yaml +201 -0
- af_filesystem_mcp-0.1.1/pixi.lock +7611 -0
- af_filesystem_mcp-0.1.1/pixi.toml +95 -0
- af_filesystem_mcp-0.1.1/pyproject.toml +174 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/__init__.py +7 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/_version.py +24 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/_version.pyi +2 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/__init__.py +3 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/broker.py +114 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/auth/local.py +25 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/cli.py +148 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/__init__.py +11 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/__main__.py +103 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/helper/ops.py +440 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/identity.py +21 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/impersonate.py +129 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/paths.py +185 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/py.typed +0 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/roots.py +21 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/server.py +197 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/__init__.py +3 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/_helpers.py +162 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/grep_files.py +78 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/list_dir.py +78 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/read_file.py +73 -0
- af_filesystem_mcp-0.1.1/src/af_filesystem_mcp/tools/stat_path.py +63 -0
- af_filesystem_mcp-0.1.1/tbump.toml +62 -0
- af_filesystem_mcp-0.1.1/tests/auth/__init__.py +0 -0
- af_filesystem_mcp-0.1.1/tests/auth/test_broker.py +116 -0
- af_filesystem_mcp-0.1.1/tests/auth/test_local.py +15 -0
- af_filesystem_mcp-0.1.1/tests/conftest.py +64 -0
- af_filesystem_mcp-0.1.1/tests/helper/__init__.py +0 -0
- af_filesystem_mcp-0.1.1/tests/helper/test_main.py +121 -0
- af_filesystem_mcp-0.1.1/tests/helper/test_ops.py +247 -0
- af_filesystem_mcp-0.1.1/tests/test_cli.py +105 -0
- af_filesystem_mcp-0.1.1/tests/test_impersonate.py +185 -0
- af_filesystem_mcp-0.1.1/tests/test_paths.py +209 -0
- af_filesystem_mcp-0.1.1/tests/test_secure_open.py +145 -0
- af_filesystem_mcp-0.1.1/tests/test_server.py +92 -0
- af_filesystem_mcp-0.1.1/tests/tools/__init__.py +0 -0
- af_filesystem_mcp-0.1.1/tests/tools/test_grep_files.py +74 -0
- af_filesystem_mcp-0.1.1/tests/tools/test_helpers.py +149 -0
- af_filesystem_mcp-0.1.1/tests/tools/test_list_dir.py +101 -0
- af_filesystem_mcp-0.1.1/tests/tools/test_read_file.py +108 -0
- af_filesystem_mcp-0.1.1/tests/tools/test_stat_path.py +66 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Maintain dependencies for GitHub Actions
|
|
4
|
+
- package-ecosystem: "github-actions"
|
|
5
|
+
directory: "/"
|
|
6
|
+
schedule:
|
|
7
|
+
interval: "monthly"
|
|
8
|
+
groups:
|
|
9
|
+
github-actions:
|
|
10
|
+
patterns:
|
|
11
|
+
- "*"
|
|
12
|
+
labels:
|
|
13
|
+
- "github-actions"
|
|
14
|
+
- "dependencies"
|
|
15
|
+
cooldown:
|
|
16
|
+
default-days: 7
|
|
17
|
+
|
|
18
|
+
# Ignore all pip dependencies to avoid PRs to update tests/constraints.txt
|
|
19
|
+
- package-ecosystem: "pip"
|
|
20
|
+
directory: "/"
|
|
21
|
+
schedule:
|
|
22
|
+
interval: "weekly"
|
|
23
|
+
ignore:
|
|
24
|
+
- dependency-name: "*"
|
|
25
|
+
cooldown:
|
|
26
|
+
default-days: 7
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: CD
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
pull_request:
|
|
6
|
+
push:
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
|
+
release:
|
|
10
|
+
types:
|
|
11
|
+
- published
|
|
12
|
+
|
|
13
|
+
concurrency:
|
|
14
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
15
|
+
cancel-in-progress: true
|
|
16
|
+
|
|
17
|
+
permissions: {}
|
|
18
|
+
|
|
19
|
+
env:
|
|
20
|
+
# Many color libraries just need this to be set to any value, but at least
|
|
21
|
+
# one distinguishes color depth, where "3" -> "256-bit color".
|
|
22
|
+
FORCE_COLOR: 3
|
|
23
|
+
|
|
24
|
+
jobs:
|
|
25
|
+
dist:
|
|
26
|
+
name: Distribution build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
permissions:
|
|
29
|
+
contents: read # checkout the repository
|
|
30
|
+
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
33
|
+
with:
|
|
34
|
+
fetch-depth: 0
|
|
35
|
+
persist-credentials: false
|
|
36
|
+
|
|
37
|
+
- uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1
|
|
38
|
+
|
|
39
|
+
publish:
|
|
40
|
+
needs: [dist]
|
|
41
|
+
name: Publish to PyPI
|
|
42
|
+
environment: pypi
|
|
43
|
+
permissions:
|
|
44
|
+
id-token: write # trusted publishing to PyPI
|
|
45
|
+
attestations: write # generate build provenance attestation
|
|
46
|
+
contents: read # required by attest-build-provenance to read repository metadata
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
if: github.event_name == 'release' && github.event.action == 'published'
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
52
|
+
with:
|
|
53
|
+
name: Packages
|
|
54
|
+
path: dist
|
|
55
|
+
|
|
56
|
+
- name: Generate artifact attestation for sdist and wheel
|
|
57
|
+
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
|
58
|
+
with:
|
|
59
|
+
subject-path: "dist/*"
|
|
60
|
+
|
|
61
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
pull_request:
|
|
6
|
+
push:
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
permissions: {}
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
# Many color libraries just need this to be set to any value, but at least
|
|
18
|
+
# one distinguishes color depth, where "3" -> "256-bit color".
|
|
19
|
+
FORCE_COLOR: 3
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
pre-commit:
|
|
23
|
+
name: Format
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read # checkout the repository
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
29
|
+
with:
|
|
30
|
+
fetch-depth: 0
|
|
31
|
+
persist-credentials: false
|
|
32
|
+
|
|
33
|
+
- uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1
|
|
34
|
+
with:
|
|
35
|
+
pixi-version: v0.68.1
|
|
36
|
+
cache: true
|
|
37
|
+
cache-key: dev-helm
|
|
38
|
+
cache-write:
|
|
39
|
+
${{ github.event_name == 'push' && github.ref_name == 'main' }}
|
|
40
|
+
environments: dev helm
|
|
41
|
+
|
|
42
|
+
- name: Run pre-commit
|
|
43
|
+
run: pixi run pre-commit --hook-stage manual
|
|
44
|
+
|
|
45
|
+
- name: Run PyLint
|
|
46
|
+
run: pixi run pylint --output-format=github
|
|
47
|
+
|
|
48
|
+
- name: Run helm lint
|
|
49
|
+
run: pixi run helm-lint
|
|
50
|
+
|
|
51
|
+
checks:
|
|
52
|
+
name: Check Python ${{ matrix.python-version }} on ${{ matrix.runs-on }}
|
|
53
|
+
runs-on: ${{ matrix.runs-on }}
|
|
54
|
+
needs: [pre-commit]
|
|
55
|
+
permissions:
|
|
56
|
+
contents: read # checkout the repository
|
|
57
|
+
strategy:
|
|
58
|
+
fail-fast: false
|
|
59
|
+
matrix:
|
|
60
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
61
|
+
runs-on: [ubuntu-latest, macos-14] # windows-latest
|
|
62
|
+
include:
|
|
63
|
+
- python-version: "3.10"
|
|
64
|
+
pixi-environment: py310
|
|
65
|
+
- python-version: "3.11"
|
|
66
|
+
pixi-environment: py311
|
|
67
|
+
- python-version: "3.12"
|
|
68
|
+
pixi-environment: py312
|
|
69
|
+
- python-version: "3.13"
|
|
70
|
+
pixi-environment: py313
|
|
71
|
+
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
74
|
+
with:
|
|
75
|
+
fetch-depth: 0
|
|
76
|
+
persist-credentials: false
|
|
77
|
+
|
|
78
|
+
- uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1
|
|
79
|
+
with:
|
|
80
|
+
pixi-version: v0.68.1
|
|
81
|
+
cache: true
|
|
82
|
+
cache-key: ${{ matrix.pixi-environment }}-${{ github.ref_name }}
|
|
83
|
+
cache-write:
|
|
84
|
+
${{ github.event_name == 'push' && github.ref_name == 'main' }}
|
|
85
|
+
environments: ${{ matrix.pixi-environment }}
|
|
86
|
+
|
|
87
|
+
- name: Test package
|
|
88
|
+
run: >-
|
|
89
|
+
pixi run -e $PIXI_ENVIRONMENT test-all -ra --cov-branch
|
|
90
|
+
--cov-report=xml --cov-report=term --junitxml=junit.xml -o
|
|
91
|
+
junit_family=legacy --durations=20
|
|
92
|
+
env:
|
|
93
|
+
PIXI_ENVIRONMENT: ${{ matrix.pixi-environment }}
|
|
94
|
+
|
|
95
|
+
- name: Upload coverage report
|
|
96
|
+
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
|
97
|
+
with:
|
|
98
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
99
|
+
|
|
100
|
+
- name: Upload test results to Codecov
|
|
101
|
+
if: ${{ !cancelled() }}
|
|
102
|
+
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
|
|
103
|
+
with:
|
|
104
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Distribution / packaging
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
*.egg
|
|
11
|
+
MANIFEST
|
|
12
|
+
|
|
13
|
+
# hatch-vcs generated version file (see pyproject.toml [tool.hatch])
|
|
14
|
+
src/*/_version.py
|
|
15
|
+
|
|
16
|
+
# Unit test / coverage reports
|
|
17
|
+
htmlcov/
|
|
18
|
+
.tox/
|
|
19
|
+
.nox/
|
|
20
|
+
.coverage
|
|
21
|
+
.coverage.*
|
|
22
|
+
.cache
|
|
23
|
+
coverage.xml
|
|
24
|
+
*.cover
|
|
25
|
+
.hypothesis/
|
|
26
|
+
.pytest_cache/
|
|
27
|
+
junit.xml
|
|
28
|
+
|
|
29
|
+
# mypy / ruff caches
|
|
30
|
+
.mypy_cache/
|
|
31
|
+
.ruff_cache/
|
|
32
|
+
.dmypy.json
|
|
33
|
+
dmypy.json
|
|
34
|
+
|
|
35
|
+
# Environments
|
|
36
|
+
.env
|
|
37
|
+
.venv/
|
|
38
|
+
env/
|
|
39
|
+
venv/
|
|
40
|
+
ENV/
|
|
41
|
+
|
|
42
|
+
# pixi
|
|
43
|
+
.pixi/
|
|
44
|
+
pixi.lock.bak
|
|
45
|
+
|
|
46
|
+
# IDE
|
|
47
|
+
.vscode/
|
|
48
|
+
.idea/
|
|
49
|
+
*.swp
|
|
50
|
+
|
|
51
|
+
# macOS
|
|
52
|
+
.DS_Store
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
ci:
|
|
2
|
+
autoupdate_commit_msg: "chore(deps): update pre-commit hooks"
|
|
3
|
+
autofix_commit_msg: "style: pre-commit fixes"
|
|
4
|
+
autoupdate_schedule: "monthly"
|
|
5
|
+
|
|
6
|
+
exclude: ^pixi.lock$
|
|
7
|
+
|
|
8
|
+
repos:
|
|
9
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
10
|
+
rev: "3e8a8703264a2f4a69428a0aa4dcb512790b2c8c" # frozen: v6.0.0
|
|
11
|
+
hooks:
|
|
12
|
+
- id: check-added-large-files
|
|
13
|
+
- id: check-case-conflict
|
|
14
|
+
- id: check-merge-conflict
|
|
15
|
+
- id: check-symlinks
|
|
16
|
+
- id: check-yaml
|
|
17
|
+
exclude: ^charts/.*/templates/
|
|
18
|
+
- id: debug-statements
|
|
19
|
+
- id: end-of-file-fixer
|
|
20
|
+
- id: mixed-line-ending
|
|
21
|
+
- id: name-tests-test
|
|
22
|
+
args: ["--pytest-test-first"]
|
|
23
|
+
- id: trailing-whitespace
|
|
24
|
+
|
|
25
|
+
- repo: https://github.com/rbubley/mirrors-prettier
|
|
26
|
+
rev: "0ee178619d696787ca73d210cc191d720868c631" # frozen: v3.9.6
|
|
27
|
+
hooks:
|
|
28
|
+
- id: prettier
|
|
29
|
+
types_or: [yaml, markdown, html, css, scss, javascript, json]
|
|
30
|
+
args: [--prose-wrap=always]
|
|
31
|
+
exclude: ^charts/.*/templates/
|
|
32
|
+
|
|
33
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
34
|
+
rev: "39d9ac5938dadb73df0564a45f163e25ff9fa6e2" # frozen: v0.16.1
|
|
35
|
+
hooks:
|
|
36
|
+
- id: ruff-check
|
|
37
|
+
args: ["--fix", "--show-fixes"]
|
|
38
|
+
- id: ruff-format
|
|
39
|
+
|
|
40
|
+
- repo: https://github.com/pre-commit/mirrors-mypy
|
|
41
|
+
rev: "41e691678310dfd3833f7ab4e180ddb014310356" # frozen: v2.3.0
|
|
42
|
+
hooks:
|
|
43
|
+
- id: mypy
|
|
44
|
+
files: src|tests
|
|
45
|
+
args: []
|
|
46
|
+
additional_dependencies:
|
|
47
|
+
- pytest
|
|
48
|
+
- mcp==2.0.0
|
|
49
|
+
- af-credentials==0.1.1
|
|
50
|
+
|
|
51
|
+
- repo: https://github.com/codespell-project/codespell
|
|
52
|
+
rev: "57b21406f092110c18776e39b0bda50d37c945c8" # frozen: v2.4.3
|
|
53
|
+
hooks:
|
|
54
|
+
- id: codespell
|
|
55
|
+
additional_dependencies:
|
|
56
|
+
- tomli; python_version<'3.11'
|
|
57
|
+
|
|
58
|
+
- repo: https://github.com/shellcheck-py/shellcheck-py
|
|
59
|
+
rev: "745eface02aef23e168a8afb6b5737818efbea95" # frozen: v0.11.0.1
|
|
60
|
+
hooks:
|
|
61
|
+
- id: shellcheck
|
|
62
|
+
|
|
63
|
+
- repo: https://github.com/abravalheri/validate-pyproject
|
|
64
|
+
rev: "4b2e70d08cb2ccd26d1fba73588de41c7a5d50b7" # frozen: v0.25
|
|
65
|
+
hooks:
|
|
66
|
+
- id: validate-pyproject
|
|
67
|
+
additional_dependencies: ["validate-pyproject-schema-store[all]"]
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# af-filesystem-mcp — Contributor Guide
|
|
2
|
+
|
|
3
|
+
MCP server that gives an AF (Analysis Facility) user browse/read access to their
|
|
4
|
+
own files on the AF's shared NFS home (`/home/<unixname>`) and Ceph data area
|
|
5
|
+
(`/data/<unixname>`) — nothing more. Read-only in v1: there is no write, delete,
|
|
6
|
+
rename, or command-execution tool anywhere in this package.
|
|
7
|
+
|
|
8
|
+
Companion backends in the same af-mcp-platform: [ami-mcp][ami-mcp] (AMI
|
|
9
|
+
metadata) and [rucio-mcp][rucio-mcp] (Rucio data management). This repo follows
|
|
10
|
+
their conventions (tool naming, broker-mode auth, Helm chart shape) wherever
|
|
11
|
+
they apply, and departs from them explicitly where this backend's job is
|
|
12
|
+
different — see "How this differs from ami-mcp/rucio-mcp" below.
|
|
13
|
+
|
|
14
|
+
[ami-mcp]: https://github.com/kratsg/ami-mcp
|
|
15
|
+
[rucio-mcp]: https://github.com/kratsg/rucio-mcp
|
|
16
|
+
|
|
17
|
+
## Origin
|
|
18
|
+
|
|
19
|
+
Designed in [maniaclab/af-mcp-platform#188][188], which also has the full design
|
|
20
|
+
rationale and the (rejected) alternative — an off-the-shelf
|
|
21
|
+
`rust-mcp-filesystem` server — and why it was rejected: stdio-only (no per-user
|
|
22
|
+
identity), and a dangling-symlink write-escape via a validate-then-open TOCTOU.
|
|
23
|
+
This package exists specifically to not have either problem.
|
|
24
|
+
|
|
25
|
+
[188]: https://github.com/maniaclab/af-mcp-platform/issues/188
|
|
26
|
+
|
|
27
|
+
## Security model
|
|
28
|
+
|
|
29
|
+
**The security boundary is impersonation, not path-string validation.** Every
|
|
30
|
+
filesystem operation for user _alice_ runs in a short-lived helper subprocess
|
|
31
|
+
that is started running AS alice's real uid/gid
|
|
32
|
+
(`asyncio.create_subprocess_exec(..., user=uid, group=gid)`), never inside the
|
|
33
|
+
long-lived async server process itself (which would mean a process-wide
|
|
34
|
+
`seteuid()` racing across concurrently in-flight requests for different users).
|
|
35
|
+
This means the kernel — and, for the NFS-mounted homes, the NFS server —
|
|
36
|
+
enforces every permission check against the real identity: even a bug in this
|
|
37
|
+
server's own path-pinning logic can only let alice reach what alice's real uid
|
|
38
|
+
could already reach. This mirrors [voms-token-service][voms-token-service]'s
|
|
39
|
+
`minting.py` impersonation pattern, adapted from `subprocess.run` to
|
|
40
|
+
`asyncio.create_subprocess_exec`.
|
|
41
|
+
|
|
42
|
+
[voms-token-service]: https://github.com/maniaclab/voms-token-service
|
|
43
|
+
|
|
44
|
+
On top of that boundary, two layers of path confinement exist as policy hygiene
|
|
45
|
+
/ prompt-injection containment (not the boundary itself — see
|
|
46
|
+
`src/af_filesystem_mcp/paths.py`'s module docstring):
|
|
47
|
+
|
|
48
|
+
- `resolve_confined(root, relative)`: a string-level check, before any I/O, that
|
|
49
|
+
fully resolves symlinks (`Path.resolve(strict=False)`, so a dangling symlink's
|
|
50
|
+
literal target is what gets checked — never falling back to checking only the
|
|
51
|
+
symlink's own parent directory the way rust-mcp-filesystem's `validate_path`
|
|
52
|
+
did) and confirms the result is still under `root`.
|
|
53
|
+
- `secure_open_confined(root, relative, flags)`: closes the TOCTOU window a
|
|
54
|
+
string-level check alone leaves open. It walks `root` → target one path
|
|
55
|
+
component at a time via `os.open(part, ..., dir_fd=parent_fd, O_NOFOLLOW)` —
|
|
56
|
+
an openat-style walk where _every_ component, intermediate or final, fails
|
|
57
|
+
outright (`ELOOP`) if it turns out to be a symlink at the moment it is
|
|
58
|
+
actually opened, rather than being silently followed. This is the direct fix
|
|
59
|
+
for rust-mcp-filesystem's actual vulnerability class.
|
|
60
|
+
|
|
61
|
+
`fs_read`/`fs_grep` (content-touching ops) always use `secure_open_confined` and
|
|
62
|
+
refuse to follow **any** symlink, even one that points to another file within
|
|
63
|
+
the same confined root — a deliberately conservative v1 restriction (see
|
|
64
|
+
`helper/ops.py`'s module docstring for why, and what it would take to loosen
|
|
65
|
+
it). `fs_list`/`fs_stat` (metadata-only, `lstat`-based, never dereferenced) use
|
|
66
|
+
the more permissive `resolve_confined`, since reporting a symlink's name and
|
|
67
|
+
literal target string never discloses file contents.
|
|
68
|
+
|
|
69
|
+
Unlike voms-token-service, this server needs **no `CAP_DAC_READ_SEARCH`**:
|
|
70
|
+
voms-token-service has exactly one code path (reading `~/.globus/*.pem`) where
|
|
71
|
+
root reads a user's file directly, before impersonating, and that capability is
|
|
72
|
+
what makes that one read work. af-filesystem-mcp has no such path at all — every
|
|
73
|
+
single byte of user data this server ever touches goes through the impersonated
|
|
74
|
+
subprocess. The container's capability set is `SETUID`+`SETGID` only (see
|
|
75
|
+
`charts/af-filesystem-mcp/values.yaml`'s `containerSecurityContext`).
|
|
76
|
+
|
|
77
|
+
## Project layout
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
src/af_filesystem_mcp/
|
|
81
|
+
├── __init__.py, _version.pyi, py.typed
|
|
82
|
+
├── cli.py # argparse: `af-filesystem-mcp serve`
|
|
83
|
+
├── server.py # stdio (local identity) + broker-mode HTTP transport
|
|
84
|
+
├── identity.py # Identity(uid, gid, unixname) -- what every call impersonates
|
|
85
|
+
├── roots.py # RootsConfig: the /home, /data prefix roots
|
|
86
|
+
├── paths.py # resolve_confined, secure_open_confined, UserRoots
|
|
87
|
+
├── impersonate.py # run_helper/run_helper_json: per-call impersonated subprocess
|
|
88
|
+
├── auth/
|
|
89
|
+
│ ├── local.py # stdio mode: the server process's own uid/gid
|
|
90
|
+
│ └── broker.py # HTTP mode: broker-issued JWT -> Identity
|
|
91
|
+
├── helper/
|
|
92
|
+
│ ├── __main__.py # `python -m af_filesystem_mcp.helper <op> <json>` CLI
|
|
93
|
+
│ └── ops.py # list_dir/stat_path/read_file/grep_files (pure functions)
|
|
94
|
+
└── tools/
|
|
95
|
+
├── _helpers.py # call_fs_op, format_error, append_next_actions
|
|
96
|
+
├── list_dir.py # fs_list
|
|
97
|
+
├── stat_path.py # fs_stat
|
|
98
|
+
├── read_file.py # fs_read
|
|
99
|
+
└── grep_files.py # fs_grep
|
|
100
|
+
tests/
|
|
101
|
+
├── conftest.py # mock_ctx, fs_roots fixtures (real tmp_path roots)
|
|
102
|
+
├── test_paths.py # path confinement: traversal, symlink escapes
|
|
103
|
+
├── test_secure_open.py # secure_open_confined: the O_NOFOLLOW walk
|
|
104
|
+
├── test_impersonate.py # impersonation subprocess wiring (mocked, like
|
|
105
|
+
│ # voms-token-service's own test pattern)
|
|
106
|
+
├── test_server.py, test_cli.py
|
|
107
|
+
├── auth/ # local + broker identity resolution
|
|
108
|
+
├── helper/ # ops.py + the __main__ CLI
|
|
109
|
+
└── tools/ # the four MCP tools, end-to-end through the
|
|
110
|
+
# real (non-impersonating-in-this-sandbox)
|
|
111
|
+
# helper subprocess -- see test_helpers.py's
|
|
112
|
+
# module docstring
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## How this differs from ami-mcp/rucio-mcp
|
|
116
|
+
|
|
117
|
+
- **No shared-secret HTTP mode.** ami-mcp/rucio-mcp's shared-secret mode serves
|
|
118
|
+
one pre-authenticated identity behind a static bearer. For this backend that
|
|
119
|
+
identity IS a uid/gid — a shared secret would mean every caller impersonates
|
|
120
|
+
the _same_ fixed user, which defeats the entire point of per-user filesystem
|
|
121
|
+
access. HTTP transport is broker-only.
|
|
122
|
+
- **No credential redeem step.** ami-mcp/rucio-mcp's broker mode verifies a
|
|
123
|
+
broker-issued JWT and then redeems a _separate_ credential (a VOMS proxy) at
|
|
124
|
+
the broker per call. This backend's bearer already carries everything it needs
|
|
125
|
+
— the `uid`/`gid`/`unixname` POSIX claims — so there is no redeem call. See
|
|
126
|
+
`auth/broker.py`'s module docstring for why `resolve_identity` re-verifies the
|
|
127
|
+
same bearer a second time (the mcp SDK's `TokenVerifier` adapter discards
|
|
128
|
+
POSIX claims by design).
|
|
129
|
+
- **No "client" object.** ami-mcp/rucio-mcp tools call a client method
|
|
130
|
+
(`pyAMI.client.Client`, `rucio.client.Client`). This backend's equivalent is
|
|
131
|
+
`af_filesystem_mcp.tools._helpers.call_fs_op`, which runs the impersonated
|
|
132
|
+
helper subprocess instead of a network call.
|
|
133
|
+
|
|
134
|
+
## Tool registration pattern
|
|
135
|
+
|
|
136
|
+
Same shape as ami-mcp/rucio-mcp: each `tools/*.py` module exports
|
|
137
|
+
`register(mcp: MCPServer) -> None`; `server.py` calls `register(mcp)` for every
|
|
138
|
+
module in `_register_all`. Tools are closures inside `register()` using the
|
|
139
|
+
`@mcp.tool()` decorator.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
# tools/mymodule.py
|
|
143
|
+
from __future__ import annotations
|
|
144
|
+
|
|
145
|
+
from typing import Any, Literal
|
|
146
|
+
|
|
147
|
+
from mcp.server.mcpserver import Context, MCPServer # noqa: TC002 (needed at runtime for eval_str signature introspection)
|
|
148
|
+
|
|
149
|
+
from af_filesystem_mcp.tools._helpers import append_next_actions, call_fs_op, format_error
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def register(mcp: MCPServer) -> None:
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
async def fs_my_tool(
|
|
155
|
+
root: Literal["home", "data"],
|
|
156
|
+
path: str = "",
|
|
157
|
+
*,
|
|
158
|
+
ctx: Context[Any, Any],
|
|
159
|
+
) -> str:
|
|
160
|
+
"""Tool description -- shown to the LLM as the tool's purpose."""
|
|
161
|
+
try:
|
|
162
|
+
result = await call_fs_op(ctx, "my_op", root, path)
|
|
163
|
+
except Exception as exc: # noqa: BLE001
|
|
164
|
+
return format_error(exc, hints=["..."])
|
|
165
|
+
return append_next_actions(str(result), ["..."])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Key conventions:
|
|
169
|
+
|
|
170
|
+
- Tool names are prefixed with `fs_` to avoid collisions.
|
|
171
|
+
- `ctx` is keyword-only (after `*`).
|
|
172
|
+
- `Context`/`MCPServer` must be imported as **real, non-`TYPE_CHECKING`**
|
|
173
|
+
imports (with a `# noqa: TC002` to satisfy ruff's type-checking-import lint) —
|
|
174
|
+
the mcp SDK's `func_metadata()` calls
|
|
175
|
+
`inspect.signature(func, eval_str=True)`, which needs `Context` to actually
|
|
176
|
+
resolve in the function's module globals at _runtime_, not just for static
|
|
177
|
+
type checking. Getting this wrong raises
|
|
178
|
+
`InvalidSignature: Unable to evaluate type annotations` the moment the tool is
|
|
179
|
+
registered.
|
|
180
|
+
- Errors are returned via `format_error(exc, hints=[...])` — never raised, never
|
|
181
|
+
a bare `f"Error: {exc}"` string.
|
|
182
|
+
- `except Exception as exc:` lines carry `# noqa: BLE001` inline;
|
|
183
|
+
`broad-exception-caught` is disabled globally in pylint (`pyproject.toml`).
|
|
184
|
+
- Use `append_next_actions(output, [...])` to suggest follow-up tool calls.
|
|
185
|
+
- If adding a new _operation_ (not just a new tool wrapping existing ops), add
|
|
186
|
+
the pure function to `helper/ops.py`, wire it into `helper/__main__ .py`'s
|
|
187
|
+
`_OPS` dict, and give it its own `PathEscapeError`/`OSError` handling
|
|
188
|
+
consistent with the existing four.
|
|
189
|
+
|
|
190
|
+
Then wire it in `server.py`:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
from af_filesystem_mcp.tools import mymodule
|
|
194
|
+
|
|
195
|
+
for _module in [..., mymodule]:
|
|
196
|
+
_module.register(mcp)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Adding a new tool
|
|
200
|
+
|
|
201
|
+
1. Decide whether it's a new _tool_ over an existing op, or needs a new op in
|
|
202
|
+
`helper/ops.py` (see "Tool registration pattern" above).
|
|
203
|
+
2. Add a new `@mcp.tool()` function inside the module's `register()`.
|
|
204
|
+
3. If creating a new module, add it to `_register_all` in `server.py`.
|
|
205
|
+
4. Write unit tests: `helper/ops.py` logic directly (no privilege needed — see
|
|
206
|
+
`tests/helper/test_ops.py`); the tool itself via `mock_ctx`/`fs_roots` from
|
|
207
|
+
`tests/conftest.py` (real subprocess, not privileged in tests, so it runs
|
|
208
|
+
unimpersonated exactly like stdio mode — see `tests/tools/test_helpers.py`'s
|
|
209
|
+
module docstring for why that is a legitimate near-end-to-end test, not a
|
|
210
|
+
shortcut).
|
|
211
|
+
5. Run `pixi run test` to verify.
|
|
212
|
+
|
|
213
|
+
## Build and test commands
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
pixi run test # quick tests (no privilege needed)
|
|
217
|
+
pixi run test-slow # all tests including any marked slow/root_only
|
|
218
|
+
pixi run lint # pre-commit + pylint
|
|
219
|
+
pixi run helm-lint # lint + smoke-render the Helm chart
|
|
220
|
+
pixi run build # build sdist + wheel
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Development setup
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
pixi install
|
|
227
|
+
pixi run pre-commit-install
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Server transports
|
|
231
|
+
|
|
232
|
+
- **stdio** (default): single caller, the process's own uid/gid
|
|
233
|
+
(`auth/ local.py`). No impersonation happens or is needed. Confined to the
|
|
234
|
+
real `$HOME` and a `--data-root` you provide.
|
|
235
|
+
- **http, broker mode** (the only HTTP mode):
|
|
236
|
+
`af-filesystem-mcp serve --transport http --broker-url <url> --broker-audience af-filesystem-mcp`.
|
|
237
|
+
Bearers are broker-issued identity JWTs (see af-mcp-platform's
|
|
238
|
+
`identityProviders[].targetOptions.af-filesystem-mcp.includePosix: true`)
|
|
239
|
+
verified via `af-credentials` (the `broker` extra:
|
|
240
|
+
`pip install af-filesystem-mcp[broker]`).
|
|
241
|
+
|
|
242
|
+
## Deployment (Helm chart)
|
|
243
|
+
|
|
244
|
+
`charts/af-filesystem-mcp/` mirrors ami-mcp's chart shape (pixi-install init
|
|
245
|
+
container, same label/helper conventions), with two AF-specific additions:
|
|
246
|
+
|
|
247
|
+
- Two read-only PVC mounts (`homes.existingClaim` at `/home`,
|
|
248
|
+
`data.existingClaim` at `/data`) — the chart does not create the underlying
|
|
249
|
+
PV; that is a sibling flux_apps manifest, exactly voms-token-service's
|
|
250
|
+
`pv-homes.yaml`/`pvc-homes.yaml` pattern. See `values.yaml`'s comments on
|
|
251
|
+
`homes`/`data` for what each PVC needs to bind to, and the **known open
|
|
252
|
+
question** on `data`: the only observed `/data` convention on the AF today (a
|
|
253
|
+
node-level hostPath mount used by htcondor execute pods, e.g.
|
|
254
|
+
`/data/projects`) is not confirmed to expose a real per-user
|
|
255
|
+
`/data/<unixname>` tree the way the NFS homes export does for
|
|
256
|
+
`/home/<unixname>` — verify before deploying.
|
|
257
|
+
- `containerSecurityContext` adds only `SETUID`/`SETGID` (see "Security model"
|
|
258
|
+
above for why not `DAC_READ_SEARCH` too).
|
|
259
|
+
|
|
260
|
+
## Non-goals (v1)
|
|
261
|
+
|
|
262
|
+
- No write, delete, rename, chmod, or any mutating tool.
|
|
263
|
+
- No arbitrary command execution (no shell, no sandboxed exec) — the tool
|
|
264
|
+
surface is deliberately the smallest useful set for "browse/read your own
|
|
265
|
+
files": `fs_list`, `fs_stat`, `fs_read`, `fs_grep`. A general-purpose
|
|
266
|
+
sandboxed shell was considered and rejected (see #188): it would need the same
|
|
267
|
+
impersonation/path-confinement machinery this package already has, plus
|
|
268
|
+
resource-limiting and auditability work an explicit, capped tool API gets for
|
|
269
|
+
free from having a fixed, enumerable set of operations.
|
|
270
|
+
- No full-tree operations (directory size, duplicate-finder, recursive copy) —
|
|
271
|
+
`fs_grep` is the one recursive op, and it is capped in files scanned and
|
|
272
|
+
matches returned.
|
|
273
|
+
|
|
274
|
+
Phase 2 (writes) is deferred by design, not by a disabled flag: there is no
|
|
275
|
+
`fs_write`/`fs_edit` code in this repository at all yet. When it is built, it
|
|
276
|
+
should ship behind an explicit, separately gated capability (a Helm value and/or
|
|
277
|
+
a distinct broker capability), soak-tested against the read-only v1 in
|
|
278
|
+
production first, and reuse `secure_open_confined` with `O_CREAT`/`O_EXCL`
|
|
279
|
+
semantics rather than a new path-resolution mechanism.
|