confluence-knowledge 0.3.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.
Files changed (32) hide show
  1. confluence_knowledge-0.3.1/PKG-INFO +77 -0
  2. confluence_knowledge-0.3.1/README.md +49 -0
  3. confluence_knowledge-0.3.1/pyproject.toml +56 -0
  4. confluence_knowledge-0.3.1/setup.cfg +4 -0
  5. confluence_knowledge-0.3.1/src/confluence_knowledge/__init__.py +3 -0
  6. confluence_knowledge-0.3.1/src/confluence_knowledge/auth.py +83 -0
  7. confluence_knowledge-0.3.1/src/confluence_knowledge/cli.py +180 -0
  8. confluence_knowledge-0.3.1/src/confluence_knowledge/client.py +152 -0
  9. confluence_knowledge-0.3.1/src/confluence_knowledge/crawl.py +236 -0
  10. confluence_knowledge-0.3.1/src/confluence_knowledge/extract.py +118 -0
  11. confluence_knowledge-0.3.1/src/confluence_knowledge/index.py +193 -0
  12. confluence_knowledge-0.3.1/src/confluence_knowledge/models.lock.json +15 -0
  13. confluence_knowledge-0.3.1/src/confluence_knowledge/registration.py +134 -0
  14. confluence_knowledge-0.3.1/src/confluence_knowledge/retrieval.py +207 -0
  15. confluence_knowledge-0.3.1/src/confluence_knowledge/server.py +341 -0
  16. confluence_knowledge-0.3.1/src/confluence_knowledge/settings.py +156 -0
  17. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/PKG-INFO +77 -0
  18. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/SOURCES.txt +30 -0
  19. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/dependency_links.txt +1 -0
  20. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/entry_points.txt +2 -0
  21. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/requires.txt +15 -0
  22. confluence_knowledge-0.3.1/src/confluence_knowledge.egg-info/top_level.txt +1 -0
  23. confluence_knowledge-0.3.1/tests/test_agent_doc_consistency.py +34 -0
  24. confluence_knowledge-0.3.1/tests/test_client_get_only.py +105 -0
  25. confluence_knowledge-0.3.1/tests/test_crawl.py +133 -0
  26. confluence_knowledge-0.3.1/tests/test_extract.py +65 -0
  27. confluence_knowledge-0.3.1/tests/test_index.py +54 -0
  28. confluence_knowledge-0.3.1/tests/test_prepare_models.py +122 -0
  29. confluence_knowledge-0.3.1/tests/test_registration.py +69 -0
  30. confluence_knowledge-0.3.1/tests/test_retrieval.py +71 -0
  31. confluence_knowledge-0.3.1/tests/test_secrets.py +31 -0
  32. confluence_knowledge-0.3.1/tests/test_server_flow.py +175 -0
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: confluence-knowledge
3
+ Version: 0.3.1
4
+ Summary: Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service
5
+ License: MIT
6
+ Project-URL: Repository, https://github.com/nkunal1992/agents
7
+ Project-URL: Documentation, https://github.com/nkunal1992/agents/blob/main/docs/confluence-knowledge.md
8
+ Project-URL: Issues, https://github.com/nkunal1992/agents/issues
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx<0.29,>=0.27
16
+ Requires-Dist: beautifulsoup4>=4.12
17
+ Requires-Dist: numpy>=1.26
18
+ Requires-Dist: keyring>=25.2
19
+ Requires-Dist: mcp<2,>=1.6
20
+ Requires-Dist: pip-system-certs>=5.0
21
+ Provides-Extra: models
22
+ Requires-Dist: sentence-transformers<4.0,>=3.0; extra == "models"
23
+ Requires-Dist: huggingface-hub>=0.26; extra == "models"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.6; extra == "dev"
28
+
29
+ # confluence-knowledge (local MCP service)
30
+
31
+ Local companion service for the `confluence-knowledge` Copilot agent.
32
+ See `../../docs/confluence-knowledge.md` for the full guide.
33
+
34
+ - GET-only `ConfluenceClient` — non-GET raises `SecurityException` pre-flight.
35
+ - Recursive subtree crawl (Cloud v2 + Data Center), resumable.
36
+ - Local embeddings (`all-MiniLM-L6-v2`) + local reranker
37
+ (`ms-marco-MiniLM-L6-v2`) — Apache-2.0, pinned in `models.lock.json`,
38
+ never a cloud embedding call.
39
+ - SQLite + FTS5 + vector-blob index under `.conf_knowledge_index/`.
40
+ - Credentials via OS keyring / env var / chmod-600 `.conf_auth.json`.
41
+
42
+ ## Dev
43
+
44
+ ```bash
45
+ python3.11+ -m venv .venv && .venv/bin/pip install -e '.[dev]'
46
+ .venv/bin/python -m pytest # all offline — no network needed
47
+ .venv/bin/python -m ruff check src tests
48
+ ```
49
+
50
+ Before every release, run the end-to-end smoke test. It spawns the real stdio
51
+ server through the MCP client SDK and uses the real models against a fake
52
+ Confluence on localhost, with simulated network latency:
53
+
54
+ ```bash
55
+ .venv/bin/pip install -e '.[models]'
56
+ CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/confluence-knowledge prepare-models
57
+ CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/python scripts/e2e_smoke.py # ends with "E2E OK"
58
+ ```
59
+
60
+ `e2e_bootstrap.py` goes further — it replays the agent's entire install flow
61
+ in a sandboxed $HOME (python discovery → venv → `pip install` from the
62
+ shipped `dist/` bundle → models → credentials → `register` → the real server
63
+ over stdio → a full Q&A round-trip). Run it before every release:
64
+
65
+ ```bash
66
+ CK_MODEL_CACHE=/tmp/ck/home/models .venv/bin/python scripts/e2e_bootstrap.py # "E2E BOOTSTRAP OK"
67
+ ```
68
+
69
+ ## CLI
70
+
71
+ | Command | Purpose |
72
+ |---|---|
73
+ | `confluence-knowledge setup` | guided env/site/token setup (hidden input, keyring) |
74
+ | `confluence-knowledge prepare-models` | fetch pinned local models once |
75
+ | `confluence-knowledge doctor` | verify config + heartbeat + models |
76
+ | `confluence-knowledge serve` | run the stdio MCP server (what the IDE launches) |
77
+ | `confluence-knowledge clean` | delete the local index (`~/.confluence-knowledge/work` unless `CONF_KNOWLEDGE_WORKDIR` is set) |
@@ -0,0 +1,49 @@
1
+ # confluence-knowledge (local MCP service)
2
+
3
+ Local companion service for the `confluence-knowledge` Copilot agent.
4
+ See `../../docs/confluence-knowledge.md` for the full guide.
5
+
6
+ - GET-only `ConfluenceClient` — non-GET raises `SecurityException` pre-flight.
7
+ - Recursive subtree crawl (Cloud v2 + Data Center), resumable.
8
+ - Local embeddings (`all-MiniLM-L6-v2`) + local reranker
9
+ (`ms-marco-MiniLM-L6-v2`) — Apache-2.0, pinned in `models.lock.json`,
10
+ never a cloud embedding call.
11
+ - SQLite + FTS5 + vector-blob index under `.conf_knowledge_index/`.
12
+ - Credentials via OS keyring / env var / chmod-600 `.conf_auth.json`.
13
+
14
+ ## Dev
15
+
16
+ ```bash
17
+ python3.11+ -m venv .venv && .venv/bin/pip install -e '.[dev]'
18
+ .venv/bin/python -m pytest # all offline — no network needed
19
+ .venv/bin/python -m ruff check src tests
20
+ ```
21
+
22
+ Before every release, run the end-to-end smoke test. It spawns the real stdio
23
+ server through the MCP client SDK and uses the real models against a fake
24
+ Confluence on localhost, with simulated network latency:
25
+
26
+ ```bash
27
+ .venv/bin/pip install -e '.[models]'
28
+ CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/confluence-knowledge prepare-models
29
+ CONF_KNOWLEDGE_HOME=/tmp/ck/home .venv/bin/python scripts/e2e_smoke.py # ends with "E2E OK"
30
+ ```
31
+
32
+ `e2e_bootstrap.py` goes further — it replays the agent's entire install flow
33
+ in a sandboxed $HOME (python discovery → venv → `pip install` from the
34
+ shipped `dist/` bundle → models → credentials → `register` → the real server
35
+ over stdio → a full Q&A round-trip). Run it before every release:
36
+
37
+ ```bash
38
+ CK_MODEL_CACHE=/tmp/ck/home/models .venv/bin/python scripts/e2e_bootstrap.py # "E2E BOOTSTRAP OK"
39
+ ```
40
+
41
+ ## CLI
42
+
43
+ | Command | Purpose |
44
+ |---|---|
45
+ | `confluence-knowledge setup` | guided env/site/token setup (hidden input, keyring) |
46
+ | `confluence-knowledge prepare-models` | fetch pinned local models once |
47
+ | `confluence-knowledge doctor` | verify config + heartbeat + models |
48
+ | `confluence-knowledge serve` | run the stdio MCP server (what the IDE launches) |
49
+ | `confluence-knowledge clean` | delete the local index (`~/.confluence-knowledge/work` unless `CONF_KNOWLEDGE_WORKDIR` is set) |
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "confluence-knowledge"
7
+ version = "0.3.1"
8
+ description = "Local, GET-only Confluence subtree indexer + grounded-Q&A MCP service"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ classifiers = [
13
+ "Operating System :: OS Independent",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ ]
18
+ dependencies = [
19
+ "httpx>=0.27,<0.29",
20
+ "beautifulsoup4>=4.12",
21
+ "numpy>=1.26",
22
+ "keyring>=25.2",
23
+ "mcp>=1.6,<2",
24
+ "pip-system-certs>=5.0", # OS cert store → works behind TLS-inspecting proxies
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/nkunal1992/agents"
29
+ Documentation = "https://github.com/nkunal1992/agents/blob/main/docs/confluence-knowledge.md"
30
+ Issues = "https://github.com/nkunal1992/agents/issues"
31
+
32
+ [project.optional-dependencies]
33
+ models = [
34
+ "sentence-transformers>=3.0,<4.0",
35
+ "huggingface-hub>=0.26",
36
+ ]
37
+ dev = [
38
+ "pytest>=8.0",
39
+ "pytest-cov>=5.0",
40
+ "ruff>=0.6",
41
+ ]
42
+
43
+ [project.scripts]
44
+ confluence-knowledge = "confluence_knowledge.cli:main"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.setuptools.package-data]
50
+ confluence_knowledge = ["models.lock.json"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.ruff]
56
+ line-length = 110
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """confluence-knowledge: local GET-only Confluence subtree index + grounded Q&A."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,83 @@
1
+ """Credential storage: OS keyring → env var → protected file (explicit choice).
2
+
3
+ Tokens are never accepted via chat, MCP tool arguments, or command-line flags —
4
+ the setup wizard prompts with getpass in the user's own terminal.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import stat
12
+ from pathlib import Path
13
+
14
+ from .settings import APP_NAME, AUTH_FILE_NAME, ensure_gitignore, reject_unsafe_path
15
+
16
+ SERVICE = APP_NAME
17
+ ENV_TOKEN = "CONF_KNOWLEDGE_TOKEN"
18
+ ENV_EMAIL = "CONF_KNOWLEDGE_EMAIL"
19
+
20
+
21
+ def _keyring_store(ref: str, secret: str) -> bool:
22
+ try:
23
+ import keyring
24
+ from keyring.backends.fail import Keyring as FailBackend
25
+ if isinstance(keyring.get_keyring(), FailBackend):
26
+ return False
27
+ keyring.set_password(SERVICE, ref, secret)
28
+ return True
29
+ except Exception: # noqa: BLE001 — keyring backends fail in many ways; fallback is intentional
30
+ return False
31
+
32
+
33
+ def _keyring_get(ref: str) -> str | None:
34
+ try:
35
+ import keyring
36
+ return keyring.get_password(SERVICE, ref)
37
+ except Exception: # noqa: BLE001 — unavailable keyring → try next provider
38
+ return None
39
+
40
+
41
+ def store_credential(workdir: Path, home: Path, ref: str, token: str) -> str:
42
+ """Persist token: keyring preferred, protected file as explicit fallback.
43
+
44
+ Returns a human description of where it went (never the token).
45
+ """
46
+ if _keyring_store(ref, token):
47
+ return "OS credential store"
48
+ # file fallback — gitignore BEFORE writing
49
+ ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
50
+ p = reject_unsafe_path(workdir / AUTH_FILE_NAME)
51
+ data = json.loads(p.read_text()) if p.exists() else {}
52
+ data[ref] = token
53
+ tmp = p.with_suffix(".tmp")
54
+ tmp.write_text(json.dumps(data))
55
+ os.replace(tmp, p)
56
+ if os.name != "nt":
57
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
58
+ return f"protected file {p} (chmod 600)"
59
+
60
+
61
+ def get_credential(workdir: Path, ref: str) -> str | None:
62
+ """Lookup order: env var (CI/scripts) → keyring → protected file."""
63
+ t = os.environ.get(ENV_TOKEN)
64
+ if t:
65
+ return t
66
+ t = _keyring_get(ref)
67
+ if t:
68
+ return t
69
+ p = workdir / AUTH_FILE_NAME
70
+ if p.exists():
71
+ data = json.loads(p.read_text())
72
+ return data.get(ref)
73
+ p2 = Path.home() / ".confluence-knowledge" / AUTH_FILE_NAME
74
+ if p2.exists():
75
+ return json.loads(p2.read_text()).get(ref)
76
+ return None
77
+
78
+
79
+ def redact(text: str, secrets: list[str]) -> str:
80
+ for s in secrets:
81
+ if s:
82
+ text = text.replace(s, "***")
83
+ return text
@@ -0,0 +1,180 @@
1
+ """confluence-knowledge CLI: setup / doctor / prepare-models / serve / clean.
2
+
3
+ The setup wizard prompts for tokens via getpass in the user's own terminal —
4
+ tokens never travel through chat or command-line arguments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import getpass
11
+ import json
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .auth import store_credential
17
+ from .client import AccessError, make_client
18
+ from .retrieval import prepare_models
19
+ from .settings import (
20
+ AUTH_FILE_NAME,
21
+ INDEX_DIR_NAME,
22
+ SetupError,
23
+ SiteProfile,
24
+ default_home,
25
+ ensure_gitignore,
26
+ resolve_workdir,
27
+ )
28
+
29
+
30
+ def _wizard() -> int:
31
+ print("confluence-knowledge setup\n")
32
+ env = ""
33
+ while env not in ("1", "2"):
34
+ env = input("Confluence environment — [1] Cloud (atlassian.net) "
35
+ "or [2] Data Center (corporate URL)? ").strip()
36
+ env = "cloud" if env == "1" else "datacenter"
37
+
38
+ if env == "cloud":
39
+ print("\nCreate a token: sign in to https://id.atlassian.com → "
40
+ "Security → Create and manage API tokens → Create API token "
41
+ "(choose Confluence read scopes if scoped tokens are offered).")
42
+ site = input("\nSite URL (e.g. https://acme.atlassian.net): ").strip()
43
+ email = input("Atlassian account email: ").strip()
44
+ else:
45
+ print("\nCreate a token: Confluence → your profile picture → Settings → "
46
+ "Personal Access Tokens → Create token (read access is enough).")
47
+ site = input("\nSite URL incl. context path (e.g. https://conf.acme.corp/confluence): ").strip()
48
+ email = None
49
+
50
+ try:
51
+ base = SiteProfile.canonical_base(env, site)
52
+ except SetupError as e:
53
+ print(f"error: {e}", file=sys.stderr)
54
+ return 2
55
+
56
+ workdir = resolve_workdir()
57
+ workdir.mkdir(parents=True, exist_ok=True)
58
+ home = default_home()
59
+ # gitignore protections BEFORE any secret/cache write
60
+ try:
61
+ ensure_gitignore(workdir, [AUTH_FILE_NAME, ".conf_knowledge_index/"])
62
+ except SetupError as e:
63
+ print(f"error: {e}", file=sys.stderr)
64
+ return 2
65
+
66
+ ref = f"{env}:{base}:{email or '-'}"
67
+ token = getpass.getpass("Token (input hidden): ")
68
+ if not token:
69
+ print("error: empty token", file=sys.stderr)
70
+ return 2
71
+
72
+ where = store_credential(workdir, home, ref, token)
73
+ SiteProfile(env, base, email, ref).save(home)
74
+
75
+ # heartbeat
76
+ try:
77
+ c = make_client(SiteProfile(env, base, email, ref), token)
78
+ ep = "wiki/rest/api/user/current" if env == "cloud" else "rest/api/user/current"
79
+ who = c.get(ep).json()
80
+ print(f"\nconnected as: {who.get('displayName') or who.get('username') or 'OK'}")
81
+ except Exception as e: # noqa: BLE001 — any connect failure (auth/network/TLS) exits non-zero
82
+ print(f"\nconnection check failed: {e}", file=sys.stderr)
83
+ return 3
84
+
85
+ print(f"credential stored: {where}")
86
+ print(f"profile: {home / 'profile.json'}")
87
+ print("\nNext: `confluence-knowledge prepare-models` to fetch the local "
88
+ "models (Apache-2.0), then point your IDE's MCP config at:\n"
89
+ f" command: {sys.executable}\n"
90
+ f" args: [\"-m\", \"confluence_knowledge.server\"]\n"
91
+ f" env: CONF_KNOWLEDGE_WORKDIR={workdir}")
92
+ return 0
93
+
94
+
95
+ def _doctor() -> int:
96
+ from .server import status
97
+ try:
98
+ s = status()
99
+ print(json.dumps(s, indent=2))
100
+ if s.get("configured"):
101
+ from .server import _client_for # internal but fine in CLI
102
+ profile = SiteProfile.load(default_home())
103
+ try:
104
+ c = _client_for(profile)
105
+ ep = ("wiki/rest/api/user/current" if profile.env == "cloud"
106
+ else "rest/api/user/current")
107
+ who = c.get(ep).json()
108
+ print("heartbeat: OK —",
109
+ who.get("displayName") or who.get("username"))
110
+ except (AccessError, SetupError) as e:
111
+ print(f"heartbeat: FAILED — {e}", file=sys.stderr)
112
+ return 3
113
+ return 0
114
+ except SetupError as e:
115
+ print(f"setup error: {e}", file=sys.stderr)
116
+ return 2
117
+
118
+
119
+ def main() -> None:
120
+ ap = argparse.ArgumentParser(prog="confluence-knowledge")
121
+ sub = ap.add_subparsers(dest="cmd", required=True)
122
+ sub.add_parser("setup", help="guided credential + config setup")
123
+ sub.add_parser("doctor", help="verify config, credentials, models")
124
+ sub.add_parser("prepare-models", help="download the pinned local models")
125
+ reg = sub.add_parser(
126
+ "register",
127
+ help="write this server into the IDE's MCP config (no manual paste)")
128
+ reg.add_argument("--ide", default="auto",
129
+ choices=["auto", "all", "vscode", "jetbrains"],
130
+ help="which config files to write (default: all that apply)")
131
+ reg.add_argument("--workspace", type=Path, default=None,
132
+ help="also write <workspace>/.vscode/mcp.json")
133
+ reg.add_argument("--remove", action="store_true",
134
+ help="remove our entry instead of adding it")
135
+ sub.add_parser("serve", help="run the stdio MCP server")
136
+ clean = sub.add_parser("clean", help="delete the local index for this repo")
137
+ clean.add_argument("--yes", action="store_true")
138
+ args = ap.parse_args()
139
+
140
+ if args.cmd == "setup":
141
+ sys.exit(_wizard())
142
+ if args.cmd == "doctor":
143
+ sys.exit(_doctor())
144
+ if args.cmd == "prepare-models":
145
+ for line in prepare_models(default_home()):
146
+ print(line)
147
+ return
148
+ if args.cmd == "register":
149
+ from .registration import register
150
+ try:
151
+ wd = (Path(os.environ["CONF_KNOWLEDGE_WORKDIR"]).expanduser().resolve()
152
+ if os.environ.get("CONF_KNOWLEDGE_WORKDIR") else None)
153
+ results = register(ide=args.ide, workspace=args.workspace,
154
+ workdir=wd, remove=args.remove)
155
+ except SetupError as e:
156
+ print(f"error: {e}", file=sys.stderr)
157
+ sys.exit(2)
158
+ for path, action in results:
159
+ print(f"{action}: {path}")
160
+ if not args.remove:
161
+ print("\nReload your IDE so Copilot picks up the server, "
162
+ "then re-ask the agent.")
163
+ return
164
+ if args.cmd == "serve":
165
+ from .server import main as serve
166
+ serve()
167
+ return
168
+ if args.cmd == "clean":
169
+ import shutil
170
+
171
+ target = resolve_workdir() / INDEX_DIR_NAME
172
+ if target.exists() and (args.yes or input(
173
+ f"delete {target}? [y/N] ").strip().lower() == "y"):
174
+ shutil.rmtree(target)
175
+ print("index deleted")
176
+ return
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,152 @@
1
+ """GET-only Confluence REST client.
2
+
3
+ Every transport call passes through request(): a non-GET method is rejected
4
+ with SecurityException before any network I/O, and paths are validated against
5
+ a per-adapter endpoint allowlist. No convenience method can bypass this.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass
12
+ from urllib.parse import urljoin, urlparse
13
+
14
+ import httpx
15
+
16
+
17
+ class SecurityException(RuntimeError):
18
+ """Raised when a write/delete/mutation HTTP operation is attempted."""
19
+
20
+
21
+ class AccessError(RuntimeError):
22
+ """Authentication, permission, or reachability failure (actionable)."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class EndpointRule:
27
+ pattern: re.Pattern # matched against the API path
28
+ purpose: str
29
+
30
+
31
+ class ConfluenceClient:
32
+ """GET-only client bound to one site base URL and one route allowlist."""
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: str,
37
+ headers: dict[str, str],
38
+ rules: list[EndpointRule],
39
+ timeout: float = 20.0,
40
+ max_bytes: int = 8 * 1024 * 1024,
41
+ session: httpx.Client | None = None,
42
+ ):
43
+ u = urlparse(base_url)
44
+ if u.scheme != "https" and u.hostname not in ("localhost", "127.0.0.1"):
45
+ raise AccessError("Confluence base URL must be https")
46
+ self._base = base_url.rstrip("/") + "/"
47
+ self._base_origin = f"{u.scheme}://{u.netloc}"
48
+ self._base_path = u.path.rstrip("/")
49
+ self._rules = rules
50
+ self._http = session or httpx.Client(
51
+ headers={"Accept": "application/json", **headers},
52
+ timeout=timeout,
53
+ follow_redirects=False, # never carry credentials through redirects
54
+ )
55
+ self._max_bytes = max_bytes
56
+
57
+ # ---- the GET-only wrapper ----
58
+
59
+ def request(self, method: str, path: str, params: dict | None = None) -> httpx.Response:
60
+ if method.upper() != "GET":
61
+ raise SecurityException(
62
+ "Error: This agent is strictly read-only. "
63
+ "Write/Delete operations are forbidden."
64
+ )
65
+ api_path = self._validate_path(path)
66
+ resp = self._http.get(urljoin(self._base, api_path.lstrip("/")), params=params)
67
+ if resp.status_code >= 400:
68
+ raise AccessError(self._describe(resp))
69
+ if resp.status_code >= 300:
70
+ raise AccessError(
71
+ f"unexpected redirect ({resp.status_code}) — check the base URL/context path"
72
+ )
73
+ if len(resp.content) > self._max_bytes:
74
+ raise AccessError(f"response exceeds {self._max_bytes} bytes — refusing to buffer")
75
+ return resp
76
+
77
+ def get(self, path: str, params: dict | None = None) -> httpx.Response:
78
+ return self.request("GET", path, params)
79
+
80
+ # ---- validation ----
81
+
82
+ def _validate_path(self, path: str) -> str:
83
+ """Normalize to an API-relative path; enforce site + endpoint bounds."""
84
+ u = urlparse(path)
85
+ if u.scheme or u.netloc:
86
+ # absolute URL (e.g. pagination next-link) must stay inside
87
+ # the configured origin + API base path
88
+ full_base = self._base_origin + self._base_path
89
+ full = f"{u.scheme}://{u.netloc}{u.path}"
90
+ if not (full == full_base or full.startswith(full_base + "/")):
91
+ raise SecurityException(
92
+ "Error: request escapes the configured Confluence site — refused."
93
+ )
94
+ rel = u.path[len(self._base_path):].lstrip("/")
95
+ elif path.startswith("/"):
96
+ # site-relative path must live under the API base path
97
+ if self._base_path and not path.startswith(self._base_path + "/"):
98
+ raise SecurityException(
99
+ "Error: request escapes the configured Confluence site — refused."
100
+ )
101
+ rel = path[len(self._base_path):].lstrip("/")
102
+ else:
103
+ rel = path
104
+ if ".." in rel:
105
+ raise SecurityException("Error: path traversal rejected.")
106
+ if not any(r.pattern.match(rel) for r in self._rules):
107
+ raise SecurityException(
108
+ "Error: This agent is strictly read-only. "
109
+ f"Endpoint not on the allowed GET list: {rel}"
110
+ )
111
+ return rel
112
+
113
+ @staticmethod
114
+ def _describe(resp: httpx.Response) -> str:
115
+ s = resp.status_code
116
+ return {
117
+ 401: "authentication failed (401) — check the token and account",
118
+ 403: "permission denied (403) — your account can't view this content",
119
+ 404: "not found (404) — check the page ID/space and base URL",
120
+ 429: "rate limited (429) — retry after the server's Retry-After",
121
+ }.get(s, f"HTTP {s} from Confluence")
122
+
123
+
124
+ # ---- adapters ----
125
+
126
+ RULES_V2 = [
127
+ EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/?$"), "page body"),
128
+ EndpointRule(re.compile(r"wiki/api/v2/pages/\d+/direct-children/?$"), "children"),
129
+ EndpointRule(re.compile(r"wiki/api/v2/folders/\d+/direct-children/?$"), "folder children"),
130
+ EndpointRule(re.compile(r"wiki/rest/api/user/current/?$"), "identity"),
131
+ ]
132
+
133
+ RULES_DC = [
134
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/?$"), "page body"),
135
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/content/\d+/child/page/?$"), "children"),
136
+ EndpointRule(re.compile(r"(?:confluence/)?rest/api/user/current/?$"), "identity"),
137
+ ]
138
+
139
+ def make_client(profile, token: str, cloud_id: str | None = None) -> ConfluenceClient:
140
+ """Build a client for the configured profile. Token only in headers."""
141
+ import base64
142
+
143
+ if profile.env == "cloud":
144
+ auth = base64.b64encode(f"{profile.email}:{token}".encode()).decode()
145
+ # scoped tokens go through the Atlassian gateway bound to one cloudId;
146
+ # the same v2 route shapes apply relative to that base path
147
+ base = (f"https://api.atlassian.com/ex/confluence/{cloud_id}"
148
+ if cloud_id else profile.base_url)
149
+ return ConfluenceClient(base, {"Authorization": f"Basic {auth}"}, RULES_V2)
150
+ return ConfluenceClient(
151
+ profile.base_url, {"Authorization": f"Bearer {token}"}, RULES_DC
152
+ )