memorykit 0.7.0__tar.gz → 0.8.0__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.
- {memorykit-0.7.0 → memorykit-0.8.0}/CHANGELOG.md +22 -0
- {memorykit-0.7.0 → memorykit-0.8.0}/PKG-INFO +25 -10
- {memorykit-0.7.0 → memorykit-0.8.0}/README.md +24 -9
- {memorykit-0.7.0 → memorykit-0.8.0}/pyproject.toml +1 -1
- {memorykit-0.7.0 → memorykit-0.8.0}/src/memorykit/__init__.py +1 -1
- {memorykit-0.7.0 → memorykit-0.8.0}/src/memorykit/mcp.py +1 -1
- {memorykit-0.7.0 → memorykit-0.8.0}/src/memorykit/provider.py +369 -22
- {memorykit-0.7.0 → memorykit-0.8.0}/.gitignore +0 -0
- {memorykit-0.7.0 → memorykit-0.8.0}/LICENSE +0 -0
- {memorykit-0.7.0 → memorykit-0.8.0}/src/memorykit/__main__.py +0 -0
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 — 2026-08-30
|
|
4
|
+
|
|
5
|
+
- Add safe automatic project detection for GitHub Copilot plugin installs.
|
|
6
|
+
`SessionStart` accepts only the host-supplied `cwd` and `sessionId`, requires
|
|
7
|
+
`sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, resolves the Git
|
|
8
|
+
top-level and a canonical `github.com/owner/repository` origin through the
|
|
9
|
+
provider's existing normalizer, and fails closed for missing, foreign-host,
|
|
10
|
+
deep-namespace, invalid, or conflicting repository context.
|
|
11
|
+
- Persist only `session_id` and normalized `project` in a private, atomic,
|
|
12
|
+
idempotent binding under `CONTEXT_KIT_MEMORY_HOME/session-bindings`. Session
|
|
13
|
+
IDs are validated as safe filenames, same-session cross-project reuse is
|
|
14
|
+
refused, explicit configuration keeps precedence, and `SessionEnd` removes
|
|
15
|
+
the matching binding. Crash leftovers cannot cross sessions because lookup is
|
|
16
|
+
keyed by the host-generated session ID.
|
|
17
|
+
- Fail closed on non-POSIX hosts, where the standard library cannot verify
|
|
18
|
+
owner-only ACLs for the binding root. Windows and other unsupported platforms
|
|
19
|
+
retain the full explicit project configuration path.
|
|
20
|
+
- Keep project resolution in the provider used by both CLI and MCP. MCP tool
|
|
21
|
+
arguments, prompt content, MCP working directory, and `PWD` remain ineligible
|
|
22
|
+
scope inputs. Hosts without Copilot's matching hook/session-ID support,
|
|
23
|
+
including APM, still require explicit project configuration.
|
|
24
|
+
|
|
3
25
|
## 0.7.0 — 2026-08-29
|
|
4
26
|
|
|
5
27
|
- Make plugin-installed MCP safe and configurable in GitHub Copilot: declare
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: memorykit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mbeacom/context-kit
|
|
6
6
|
Project-URL: Repository, https://github.com/mbeacom/context-kit
|
|
@@ -126,7 +126,7 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
|
|
|
126
126
|
|
|
127
127
|
## Local-only reviewed records
|
|
128
128
|
|
|
129
|
-
Python 3 and Git on `PATH` are required. Configure an explicit project and
|
|
129
|
+
Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
|
|
130
130
|
plugin root:
|
|
131
131
|
|
|
132
132
|
```bash
|
|
@@ -237,13 +237,23 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
|
|
|
237
237
|
CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
The bundled `.mcp.json` forwards
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
240
|
+
The bundled `.mcp.json` forwards explicit project/home configuration into the
|
|
241
|
+
plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
|
|
242
|
+
the active project through trusted host data: `SessionStart` requires payload
|
|
243
|
+
`sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
|
|
244
|
+
`cwd` to the Git top-level and accepts only a canonical
|
|
245
|
+
`github.com/owner/repository` origin on POSIX. It stores only that session ID and
|
|
246
|
+
project under the private memory home and removes the binding on `SessionEnd`.
|
|
247
|
+
|
|
248
|
+
Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
|
|
249
|
+
`PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
|
|
250
|
+
precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
|
|
251
|
+
arguments never select scope. APM, package-only use, and hosts without the
|
|
252
|
+
matching Copilot hook/session ID still require explicit configuration. The same
|
|
253
|
+
is true on Windows and for other Git hosts or deeper repository namespaces,
|
|
254
|
+
whose privacy or identity cannot be represented safely by this automatic path.
|
|
255
|
+
MCP capture records must use an absolute `source` path because plugin hosts may
|
|
256
|
+
launch the server outside the active repository.
|
|
247
257
|
|
|
248
258
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
249
259
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
@@ -254,7 +264,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
|
|
|
254
264
|
|
|
255
265
|
## Opt-in lifecycle queue
|
|
256
266
|
|
|
257
|
-
|
|
267
|
+
Recall and payload-queue behavior is inert until enabled:
|
|
258
268
|
|
|
259
269
|
```bash
|
|
260
270
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -266,6 +276,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
|
|
|
266
276
|
both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
|
|
267
277
|
explicit command there.
|
|
268
278
|
|
|
279
|
+
Copilot's routing-only `SessionStart` binding is the bounded exception: it is
|
|
280
|
+
created even when both switches are off, contains no transcript or secret, and
|
|
281
|
+
is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
|
|
282
|
+
metadata, not automatic memory capture.
|
|
283
|
+
|
|
269
284
|
## Components
|
|
270
285
|
|
|
271
286
|
| Component | Purpose |
|
|
@@ -83,7 +83,7 @@ is the reverse of the `indexkit` launcher's preference, and deliberate.
|
|
|
83
83
|
|
|
84
84
|
## Local-only reviewed records
|
|
85
85
|
|
|
86
|
-
Python 3 and Git on `PATH` are required. Configure an explicit project and
|
|
86
|
+
Python 3.10+ and Git on `PATH` are required. Configure an explicit project and
|
|
87
87
|
plugin root:
|
|
88
88
|
|
|
89
89
|
```bash
|
|
@@ -194,13 +194,23 @@ CONTEXT_KIT_MEMORY_PROJECT=owner/repository \
|
|
|
194
194
|
CONTEXT_KIT_MEMORY_PROJECT=owner/repository memorykit-mcp
|
|
195
195
|
```
|
|
196
196
|
|
|
197
|
-
The bundled `.mcp.json` forwards
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
197
|
+
The bundled `.mcp.json` forwards explicit project/home configuration into the
|
|
198
|
+
plugin process. GitHub Copilot Desktop can additionally bind the MCP child to
|
|
199
|
+
the active project through trusted host data: `SessionStart` requires payload
|
|
200
|
+
`sessionId` to exactly match `COPILOT_AGENT_SESSION_ID`, then resolves payload
|
|
201
|
+
`cwd` to the Git top-level and accepts only a canonical
|
|
202
|
+
`github.com/owner/repository` origin on POSIX. It stores only that session ID and
|
|
203
|
+
project under the private memory home and removes the binding on `SessionEnd`.
|
|
204
|
+
|
|
205
|
+
Explicit `--project`, `CONTEXT_KIT_MEMORY_PROJECT`, deprecated
|
|
206
|
+
`PRODUCTIVITY_SKILLS_MEMORY_PROJECT`, and `CLAUDE_PLUGIN_OPTION_PROJECT` take
|
|
207
|
+
precedence, in that order, over the binding. MCP cwd, `PWD`, prompts, and tool
|
|
208
|
+
arguments never select scope. APM, package-only use, and hosts without the
|
|
209
|
+
matching Copilot hook/session ID still require explicit configuration. The same
|
|
210
|
+
is true on Windows and for other Git hosts or deeper repository namespaces,
|
|
211
|
+
whose privacy or identity cannot be represented safely by this automatic path.
|
|
212
|
+
MCP capture records must use an absolute `source` path because plugin hosts may
|
|
213
|
+
launch the server outside the active repository.
|
|
204
214
|
|
|
205
215
|
The surface can propose memory but **cannot activate it**: a record whose
|
|
206
216
|
frontmatter is not `review: proposed` is refused, and proposals stay out of
|
|
@@ -211,7 +221,7 @@ See [`references/mcp-server.md`](skills/memory-workflows/references/mcp-server.m
|
|
|
211
221
|
|
|
212
222
|
## Opt-in lifecycle queue
|
|
213
223
|
|
|
214
|
-
|
|
224
|
+
Recall and payload-queue behavior is inert until enabled:
|
|
215
225
|
|
|
216
226
|
```bash
|
|
217
227
|
export CONTEXT_KIT_MEMORY_PROJECT=owner/repository
|
|
@@ -223,6 +233,11 @@ memory records or mutate a provider store. Claude Code and GitHub Copilot CLI
|
|
|
223
233
|
both load `hooks/hooks.json`; APM does not deploy hooks, so capture stays an
|
|
224
234
|
explicit command there.
|
|
225
235
|
|
|
236
|
+
Copilot's routing-only `SessionStart` binding is the bounded exception: it is
|
|
237
|
+
created even when both switches are off, contains no transcript or secret, and
|
|
238
|
+
is cleaned at `SessionEnd` (not `Stop`/`agentStop`). It is ephemeral routing
|
|
239
|
+
metadata, not automatic memory capture.
|
|
240
|
+
|
|
226
241
|
## Components
|
|
227
242
|
|
|
228
243
|
| Component | Purpose |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "memorykit"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "Provenance-bound durable memory: an immutable record contract, a validator, append-only review state, and a standard-library stdio MCP server. No Python dependencies; requires git on PATH."
|
|
5
5
|
requires-python = ">=3.10"
|
|
6
6
|
keywords = [
|
|
@@ -26,7 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Any
|
|
27
27
|
|
|
28
28
|
SERVER_NAME = "context-kit-memory"
|
|
29
|
-
SERVER_VERSION = "0.
|
|
29
|
+
SERVER_VERSION = "0.8.0"
|
|
30
30
|
# Newest first. The client's requested version is echoed when supported.
|
|
31
31
|
SUPPORTED_PROTOCOLS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
32
32
|
# The provider is this package's sibling module, so one path is correct in both
|
|
@@ -17,6 +17,7 @@ import json
|
|
|
17
17
|
import os
|
|
18
18
|
import re
|
|
19
19
|
import shutil
|
|
20
|
+
import stat
|
|
20
21
|
import subprocess
|
|
21
22
|
import sys
|
|
22
23
|
import tempfile
|
|
@@ -25,6 +26,7 @@ import uuid
|
|
|
25
26
|
from dataclasses import dataclass
|
|
26
27
|
from datetime import datetime, timezone
|
|
27
28
|
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlsplit
|
|
28
30
|
|
|
29
31
|
SCHEMA = "context-kit/memory-v1"
|
|
30
32
|
MAX_BYTES = 32 * 1024
|
|
@@ -54,9 +56,13 @@ WAKE_SCHEMA = "context-kit/memory-wake-v1"
|
|
|
54
56
|
# Session mining recognizes GitHub Copilot CLI event logs
|
|
55
57
|
# (`~/.copilot/session-state/<session-id>/events.jsonl`).
|
|
56
58
|
SESSION_PRODUCER = "github-copilot-cli"
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
+
# Session identifiers arrive from Copilot logs and lifecycle payloads and are
|
|
60
|
+
# used in filenames, so they must remain one bounded path component.
|
|
59
61
|
SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
62
|
+
COPILOT_SESSION_ID_ENV = "COPILOT_AGENT_SESSION_ID"
|
|
63
|
+
SESSION_BINDING_DIRNAME = "session-bindings"
|
|
64
|
+
SESSION_BINDING_MAX_BYTES = 512
|
|
65
|
+
SESSION_BINDING_KEYS = frozenset({"session_id", "project"})
|
|
60
66
|
MAX_TURN_CHARS = 2000
|
|
61
67
|
MAX_CANDIDATE_TURNS = 400
|
|
62
68
|
# A wake digest primes a session, so it competes with real work for context.
|
|
@@ -437,7 +443,7 @@ def _indexkit_home() -> Path:
|
|
|
437
443
|
return default_root / "indexkit"
|
|
438
444
|
|
|
439
445
|
|
|
440
|
-
def _config(args: argparse.Namespace) -> Config:
|
|
446
|
+
def _config(args: argparse.Namespace, *, allow_session_binding: bool = True) -> Config:
|
|
441
447
|
provider = (
|
|
442
448
|
getattr(args, "provider", None)
|
|
443
449
|
or _first_env(
|
|
@@ -465,6 +471,8 @@ def _config(args: argparse.Namespace) -> Config:
|
|
|
465
471
|
"PRODUCTIVITY_SKILLS_MEMORY_PROJECT",
|
|
466
472
|
"CLAUDE_PLUGIN_OPTION_PROJECT",
|
|
467
473
|
)
|
|
474
|
+
if not project and allow_session_binding:
|
|
475
|
+
project = _copilot_session_binding_project(home)
|
|
468
476
|
auto_value = _first_env(
|
|
469
477
|
"CONTEXT_KIT_MEMORY_AUTO_CAPTURE",
|
|
470
478
|
"PRODUCTIVITY_SKILLS_MEMORY_AUTO_CAPTURE",
|
|
@@ -772,6 +780,288 @@ def _normalize_repository(remote: str) -> str:
|
|
|
772
780
|
return "/".join(parts[-2:])
|
|
773
781
|
|
|
774
782
|
|
|
783
|
+
def _normalize_copilot_origin(remote: str) -> str:
|
|
784
|
+
"""Return an unambiguous project identity for automatic Copilot binding.
|
|
785
|
+
|
|
786
|
+
The memory contract represents repositories as `owner/name`, so automatic
|
|
787
|
+
detection cannot safely collapse a different host or a deeper namespace
|
|
788
|
+
onto that shape. Those repositories remain available through explicit
|
|
789
|
+
project configuration.
|
|
790
|
+
"""
|
|
791
|
+
value = remote.strip()
|
|
792
|
+
host = ""
|
|
793
|
+
path = ""
|
|
794
|
+
if value.startswith("git@") and ":" in value:
|
|
795
|
+
authority, path = value.split(":", 1)
|
|
796
|
+
host = authority.rsplit("@", 1)[-1]
|
|
797
|
+
elif "://" in value:
|
|
798
|
+
parsed = urlsplit(value)
|
|
799
|
+
host = parsed.hostname or ""
|
|
800
|
+
path = parsed.path
|
|
801
|
+
if parsed.query or parsed.fragment:
|
|
802
|
+
raise Refusal(
|
|
803
|
+
"automatic Copilot project detection refuses origin queries "
|
|
804
|
+
"and fragments"
|
|
805
|
+
)
|
|
806
|
+
else:
|
|
807
|
+
raise Refusal(
|
|
808
|
+
"automatic Copilot project detection requires a canonical GitHub origin"
|
|
809
|
+
)
|
|
810
|
+
if host.lower() != "github.com":
|
|
811
|
+
raise Refusal(
|
|
812
|
+
"automatic Copilot project detection requires a github.com origin"
|
|
813
|
+
)
|
|
814
|
+
parts = [part for part in path.strip("/").split("/") if part]
|
|
815
|
+
if parts and parts[-1].endswith(".git"):
|
|
816
|
+
parts[-1] = parts[-1][:-4]
|
|
817
|
+
if len(parts) != 2 or not all(parts):
|
|
818
|
+
raise Refusal(
|
|
819
|
+
"automatic Copilot project detection requires exactly owner/repository"
|
|
820
|
+
)
|
|
821
|
+
project = _normalize_repository(value)
|
|
822
|
+
if project != "/".join(parts) or not REPOSITORY_RE.fullmatch(project):
|
|
823
|
+
raise Refusal("repository origin is not a concrete owner/name identity")
|
|
824
|
+
return project
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _validate_session_id(value: object) -> str:
|
|
828
|
+
if not isinstance(value, str) or not SESSION_ID_RE.fullmatch(value):
|
|
829
|
+
raise Refusal("Copilot session id is not a safe path component")
|
|
830
|
+
return value
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _copilot_session_id() -> str | None:
|
|
834
|
+
value = os.environ.get(COPILOT_SESSION_ID_ENV)
|
|
835
|
+
if not value:
|
|
836
|
+
return None
|
|
837
|
+
return _validate_session_id(value)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _private_session_bindings_supported() -> bool:
|
|
841
|
+
return os.name == "posix"
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _session_binding_directory(home: Path, *, create: bool) -> Path | None:
|
|
845
|
+
if not _private_session_bindings_supported():
|
|
846
|
+
raise Refusal(
|
|
847
|
+
"automatic Copilot project detection requires verifiable POSIX "
|
|
848
|
+
"owner-only permissions; set CONTEXT_KIT_MEMORY_PROJECT explicitly"
|
|
849
|
+
)
|
|
850
|
+
directory = home / SESSION_BINDING_DIRNAME
|
|
851
|
+
if create:
|
|
852
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
853
|
+
try:
|
|
854
|
+
os.mkdir(directory, 0o700)
|
|
855
|
+
except FileExistsError:
|
|
856
|
+
pass
|
|
857
|
+
try:
|
|
858
|
+
metadata = directory.lstat()
|
|
859
|
+
except FileNotFoundError:
|
|
860
|
+
return None
|
|
861
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
862
|
+
raise Refusal(f"session binding root is not a real directory: {directory}")
|
|
863
|
+
if os.name == "posix":
|
|
864
|
+
if metadata.st_uid != os.getuid():
|
|
865
|
+
raise Refusal(f"session binding root is owned by another user: {directory}")
|
|
866
|
+
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
|
867
|
+
raise Refusal(f"session binding root permissions must be 0700: {directory}")
|
|
868
|
+
return directory
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _session_binding_path(
|
|
872
|
+
home: Path, session_id: str, *, create_directory: bool
|
|
873
|
+
) -> Path | None:
|
|
874
|
+
safe_id = _validate_session_id(session_id)
|
|
875
|
+
directory = _session_binding_directory(home, create=create_directory)
|
|
876
|
+
if directory is None:
|
|
877
|
+
return None
|
|
878
|
+
return directory / f"{safe_id}.json"
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _load_session_binding(home: Path, session_id: str) -> dict[str, object] | None:
|
|
882
|
+
path = _session_binding_path(home, session_id, create_directory=False)
|
|
883
|
+
if path is None:
|
|
884
|
+
return None
|
|
885
|
+
flags = os.O_RDONLY
|
|
886
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
887
|
+
flags |= os.O_NOFOLLOW
|
|
888
|
+
try:
|
|
889
|
+
descriptor = os.open(path, flags)
|
|
890
|
+
except FileNotFoundError:
|
|
891
|
+
return None
|
|
892
|
+
except OSError as exc:
|
|
893
|
+
raise Refusal(f"cannot safely open Copilot session binding: {path}") from exc
|
|
894
|
+
try:
|
|
895
|
+
metadata = os.fstat(descriptor)
|
|
896
|
+
if not stat.S_ISREG(metadata.st_mode):
|
|
897
|
+
raise Refusal(f"Copilot session binding is not a regular file: {path}")
|
|
898
|
+
if os.name == "posix":
|
|
899
|
+
if metadata.st_uid != os.getuid():
|
|
900
|
+
raise Refusal(
|
|
901
|
+
f"Copilot session binding is owned by another user: {path}"
|
|
902
|
+
)
|
|
903
|
+
if stat.S_IMODE(metadata.st_mode) & 0o077:
|
|
904
|
+
raise Refusal(
|
|
905
|
+
f"Copilot session binding permissions must be 0600: {path}"
|
|
906
|
+
)
|
|
907
|
+
if metadata.st_size > SESSION_BINDING_MAX_BYTES:
|
|
908
|
+
raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
|
|
909
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
910
|
+
descriptor = -1
|
|
911
|
+
raw = stream.read(SESSION_BINDING_MAX_BYTES + 1)
|
|
912
|
+
finally:
|
|
913
|
+
if descriptor >= 0:
|
|
914
|
+
os.close(descriptor)
|
|
915
|
+
if len(raw) > SESSION_BINDING_MAX_BYTES:
|
|
916
|
+
raise Refusal(f"Copilot session binding is unexpectedly large: {path}")
|
|
917
|
+
try:
|
|
918
|
+
payload = json.loads(raw)
|
|
919
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
920
|
+
raise Refusal(f"Copilot session binding is invalid JSON: {path}") from exc
|
|
921
|
+
if not isinstance(payload, dict) or set(payload) != SESSION_BINDING_KEYS:
|
|
922
|
+
raise Refusal(f"Copilot session binding has unexpected fields: {path}")
|
|
923
|
+
if payload.get("session_id") != session_id:
|
|
924
|
+
raise Refusal(f"Copilot session binding id does not match its filename: {path}")
|
|
925
|
+
return payload
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _read_session_binding(home: Path, session_id: str) -> str | None:
|
|
929
|
+
payload = _load_session_binding(home, session_id)
|
|
930
|
+
if payload is None:
|
|
931
|
+
return None
|
|
932
|
+
path = _session_binding_path(home, session_id, create_directory=False)
|
|
933
|
+
assert path is not None
|
|
934
|
+
project = payload.get("project")
|
|
935
|
+
if not isinstance(project, str) or not REPOSITORY_RE.fullmatch(project):
|
|
936
|
+
raise Refusal(f"Copilot session binding has an invalid project: {path}")
|
|
937
|
+
return project
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _session_binding_bytes(session_id: str, project: str) -> bytes:
|
|
941
|
+
raw = (
|
|
942
|
+
json.dumps(
|
|
943
|
+
{"session_id": session_id, "project": project},
|
|
944
|
+
sort_keys=True,
|
|
945
|
+
separators=(",", ":"),
|
|
946
|
+
)
|
|
947
|
+
+ "\n"
|
|
948
|
+
).encode("utf-8")
|
|
949
|
+
if len(raw) > SESSION_BINDING_MAX_BYTES:
|
|
950
|
+
raise Refusal("Copilot session binding exceeds its size limit")
|
|
951
|
+
return raw
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def _replace_session_binding(path: Path, *, session_id: str, project: str) -> None:
|
|
955
|
+
raw = _session_binding_bytes(session_id, project)
|
|
956
|
+
with tempfile.NamedTemporaryFile(
|
|
957
|
+
dir=path.parent,
|
|
958
|
+
prefix=f".{path.name}.",
|
|
959
|
+
delete=False,
|
|
960
|
+
) as handle:
|
|
961
|
+
handle.write(raw)
|
|
962
|
+
handle.flush()
|
|
963
|
+
os.fsync(handle.fileno())
|
|
964
|
+
temporary = Path(handle.name)
|
|
965
|
+
os.chmod(temporary, 0o600)
|
|
966
|
+
try:
|
|
967
|
+
os.replace(temporary, path)
|
|
968
|
+
finally:
|
|
969
|
+
temporary.unlink(missing_ok=True)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _write_session_binding(home: Path, session_id: str, project: str) -> str:
|
|
973
|
+
if not REPOSITORY_RE.fullmatch(project):
|
|
974
|
+
raise Refusal("memory project must be a concrete owner/name identity")
|
|
975
|
+
path = _session_binding_path(home, session_id, create_directory=True)
|
|
976
|
+
assert path is not None
|
|
977
|
+
raw = _session_binding_bytes(session_id, project)
|
|
978
|
+
with tempfile.NamedTemporaryFile(
|
|
979
|
+
dir=path.parent,
|
|
980
|
+
prefix=f".{path.name}.",
|
|
981
|
+
delete=False,
|
|
982
|
+
) as handle:
|
|
983
|
+
handle.write(raw)
|
|
984
|
+
handle.flush()
|
|
985
|
+
os.fsync(handle.fileno())
|
|
986
|
+
temporary = Path(handle.name)
|
|
987
|
+
os.chmod(temporary, 0o600)
|
|
988
|
+
try:
|
|
989
|
+
try:
|
|
990
|
+
os.link(temporary, path, follow_symlinks=False)
|
|
991
|
+
except FileExistsError:
|
|
992
|
+
existing_payload = _load_session_binding(home, session_id)
|
|
993
|
+
assert existing_payload is not None
|
|
994
|
+
existing = existing_payload.get("project")
|
|
995
|
+
if existing == project:
|
|
996
|
+
return "unchanged"
|
|
997
|
+
if not isinstance(existing, str) or not REPOSITORY_RE.fullmatch(existing):
|
|
998
|
+
raise Refusal("Copilot session binding is already conflicted")
|
|
999
|
+
# A conflicting SessionStart must not leave the old project
|
|
1000
|
+
# addressable through the same session id. Keep only the two-field
|
|
1001
|
+
# minimum payload, but make the project deliberately invalid so all
|
|
1002
|
+
# later resolution fails closed until matching SessionEnd cleanup.
|
|
1003
|
+
_replace_session_binding(path, session_id=session_id, project="")
|
|
1004
|
+
raise Refusal(
|
|
1005
|
+
"refusing to reuse a Copilot session binding for another project"
|
|
1006
|
+
) from None
|
|
1007
|
+
finally:
|
|
1008
|
+
temporary.unlink(missing_ok=True)
|
|
1009
|
+
return "created"
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
def _remove_session_binding(home: Path, session_id: str) -> bool:
|
|
1013
|
+
path = _session_binding_path(home, session_id, create_directory=False)
|
|
1014
|
+
if path is None:
|
|
1015
|
+
return False
|
|
1016
|
+
if _load_session_binding(home, session_id) is None:
|
|
1017
|
+
return False
|
|
1018
|
+
path.unlink()
|
|
1019
|
+
return True
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _copilot_session_binding_project(home: Path) -> str | None:
|
|
1023
|
+
session_id = _copilot_session_id()
|
|
1024
|
+
if session_id is None:
|
|
1025
|
+
return None
|
|
1026
|
+
return _read_session_binding(home, session_id)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _decode_hook_payload(payload: bytes) -> dict[str, object]:
|
|
1030
|
+
try:
|
|
1031
|
+
decoded = json.loads(payload)
|
|
1032
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
1033
|
+
raise Refusal(f"hook payload must be valid JSON: {exc}") from exc
|
|
1034
|
+
if not isinstance(decoded, dict):
|
|
1035
|
+
raise Refusal("hook payload must be a JSON object")
|
|
1036
|
+
return decoded
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _matching_copilot_payload_session(
|
|
1040
|
+
payload: dict[str, object],
|
|
1041
|
+
) -> str | None:
|
|
1042
|
+
session_id = _copilot_session_id()
|
|
1043
|
+
if session_id is None:
|
|
1044
|
+
return None
|
|
1045
|
+
payload_session_id = _validate_session_id(payload.get("sessionId"))
|
|
1046
|
+
if payload_session_id != session_id:
|
|
1047
|
+
raise Refusal("Copilot environment and hook payload session ids do not match")
|
|
1048
|
+
return session_id
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _project_from_session_start(payload: dict[str, object]) -> str:
|
|
1052
|
+
cwd = payload.get("cwd")
|
|
1053
|
+
if not isinstance(cwd, str) or not cwd or not Path(cwd).is_absolute():
|
|
1054
|
+
raise Refusal("Copilot SessionStart cwd must be an absolute path")
|
|
1055
|
+
repository = Path(cwd).resolve(strict=True)
|
|
1056
|
+
if not repository.is_dir():
|
|
1057
|
+
raise Refusal("Copilot SessionStart cwd must be a directory")
|
|
1058
|
+
top_level = Path(_git(repository, "rev-parse", "--show-toplevel"))
|
|
1059
|
+
if not top_level.is_absolute():
|
|
1060
|
+
raise Refusal("git returned a non-absolute repository top-level")
|
|
1061
|
+
root = top_level.resolve(strict=True)
|
|
1062
|
+
return _normalize_copilot_origin(_git(root, "remote", "get-url", "origin"))
|
|
1063
|
+
|
|
1064
|
+
|
|
775
1065
|
def _assert_project_matches(metadata: dict[str, object], config: Config) -> None:
|
|
776
1066
|
project = config.project
|
|
777
1067
|
if not project:
|
|
@@ -2739,32 +3029,86 @@ def _hook_recall(config: Config) -> int:
|
|
|
2739
3029
|
return 0
|
|
2740
3030
|
|
|
2741
3031
|
|
|
3032
|
+
def _hook_session_start(config: Config, payload: bytes) -> int:
|
|
3033
|
+
effective = config
|
|
3034
|
+
try:
|
|
3035
|
+
decoded = _decode_hook_payload(payload)
|
|
3036
|
+
session_id = _matching_copilot_payload_session(decoded)
|
|
3037
|
+
if session_id is not None:
|
|
3038
|
+
project = _project_from_session_start(decoded)
|
|
3039
|
+
_write_session_binding(config.home, session_id, project)
|
|
3040
|
+
if not config.project:
|
|
3041
|
+
effective = Config(
|
|
3042
|
+
provider=config.provider,
|
|
3043
|
+
home=config.home,
|
|
3044
|
+
project=project,
|
|
3045
|
+
auto_capture=config.auto_capture,
|
|
3046
|
+
recall_on_start=config.recall_on_start,
|
|
3047
|
+
)
|
|
3048
|
+
except (OSError, Refusal) as exc:
|
|
3049
|
+
# Routing metadata must fail closed without breaking session startup.
|
|
3050
|
+
print(f"memory session binding skipped: {exc}", file=sys.stderr)
|
|
3051
|
+
return _hook_recall(effective)
|
|
3052
|
+
|
|
3053
|
+
|
|
2742
3054
|
def _run_hook(event: str, config: Config, payload: bytes) -> int:
|
|
2743
3055
|
if event == "session-start":
|
|
2744
|
-
return
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
3056
|
+
return _hook_session_start(config, payload)
|
|
3057
|
+
|
|
3058
|
+
decoded: dict[str, object] | None = None
|
|
3059
|
+
cleanup_session_id: str | None = None
|
|
3060
|
+
effective = config
|
|
3061
|
+
if event == "session-end" and os.environ.get(COPILOT_SESSION_ID_ENV):
|
|
3062
|
+
try:
|
|
3063
|
+
decoded = _decode_hook_payload(payload)
|
|
3064
|
+
cleanup_session_id = _matching_copilot_payload_session(decoded)
|
|
3065
|
+
except (OSError, Refusal) as exc:
|
|
3066
|
+
print(f"memory session binding cleanup skipped: {exc}", file=sys.stderr)
|
|
3067
|
+
cleanup_session_id = None
|
|
3068
|
+
if cleanup_session_id is not None and not config.project:
|
|
3069
|
+
try:
|
|
3070
|
+
bound_project = _read_session_binding(config.home, cleanup_session_id)
|
|
3071
|
+
if bound_project:
|
|
3072
|
+
effective = Config(
|
|
3073
|
+
provider=config.provider,
|
|
3074
|
+
home=config.home,
|
|
3075
|
+
project=bound_project,
|
|
3076
|
+
auto_capture=config.auto_capture,
|
|
3077
|
+
recall_on_start=config.recall_on_start,
|
|
3078
|
+
)
|
|
3079
|
+
except (OSError, Refusal):
|
|
3080
|
+
# Cleanup below still removes a private conflicted binding, but
|
|
3081
|
+
# no memory operation may resolve through it.
|
|
3082
|
+
pass
|
|
3083
|
+
|
|
3084
|
+
response: dict[str, object] = {}
|
|
2748
3085
|
try:
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
json.dumps(
|
|
2760
|
-
{
|
|
3086
|
+
if effective.auto_capture:
|
|
3087
|
+
if decoded is None:
|
|
3088
|
+
decoded = _decode_hook_payload(payload)
|
|
3089
|
+
pending_dir = effective.home / "pending-hooks" / effective.project_slug
|
|
3090
|
+
pending = _new_write_once_path(pending_dir, f"-{event}.json")
|
|
3091
|
+
if _write_once(pending, payload) != "created":
|
|
3092
|
+
raise Refusal(
|
|
3093
|
+
f"refusing to reuse a generated hook payload path: {pending}"
|
|
3094
|
+
)
|
|
3095
|
+
response = {
|
|
2761
3096
|
"status": "queued-for-review",
|
|
2762
3097
|
"event": event,
|
|
2763
3098
|
"pending": str(pending),
|
|
2764
3099
|
"provider_invoked": False,
|
|
2765
3100
|
}
|
|
2766
|
-
|
|
2767
|
-
|
|
3101
|
+
finally:
|
|
3102
|
+
if cleanup_session_id is not None:
|
|
3103
|
+
try:
|
|
3104
|
+
_remove_session_binding(effective.home, cleanup_session_id)
|
|
3105
|
+
except (OSError, Refusal) as exc:
|
|
3106
|
+
# An unsafe or changed binding is left untouched for inspection.
|
|
3107
|
+
print(
|
|
3108
|
+
f"memory session binding cleanup skipped: {exc}",
|
|
3109
|
+
file=sys.stderr,
|
|
3110
|
+
)
|
|
3111
|
+
print(json.dumps(response))
|
|
2768
3112
|
return 0
|
|
2769
3113
|
|
|
2770
3114
|
|
|
@@ -2902,7 +3246,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
2902
3246
|
print(json.dumps({"status": "valid", "id": metadata["id"]}))
|
|
2903
3247
|
return 0
|
|
2904
3248
|
|
|
2905
|
-
|
|
3249
|
+
allow_session_binding = not (
|
|
3250
|
+
args.command == "hook" and args.event in {"session-start", "session-end"}
|
|
3251
|
+
)
|
|
3252
|
+
config = _config(args, allow_session_binding=allow_session_binding)
|
|
2906
3253
|
if args.command == "capture":
|
|
2907
3254
|
return _capture_memory(args, config)
|
|
2908
3255
|
if args.command == "archive-handoff":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|