pactkit-codex 3.0.3__tar.gz → 3.0.5__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.
- {pactkit_codex-3.0.3/src/pactkit_codex.egg-info → pactkit_codex-3.0.5}/PKG-INFO +1 -1
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/pyproject.toml +1 -1
- pactkit_codex-3.0.5/src/pactkit_codex/__init__.py +10 -0
- pactkit_codex-3.0.5/src/pactkit_codex/declarations.py +141 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex/deployer.py +104 -56
- pactkit_codex-3.0.5/src/pactkit_codex/enforcement.py +77 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5/src/pactkit_codex.egg-info}/PKG-INFO +1 -1
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex.egg-info/SOURCES.txt +2 -0
- pactkit_codex-3.0.5/tests/test_host_body_snapshot.py +239 -0
- pactkit_codex-3.0.3/src/pactkit_codex/__init__.py +0 -5
- pactkit_codex-3.0.3/tests/test_host_body_snapshot.py +0 -126
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/LICENSE +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/README.md +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/setup.cfg +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex.egg-info/dependency_links.txt +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex.egg-info/entry_points.txt +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex.egg-info/requires.txt +0 -0
- {pactkit_codex-3.0.3 → pactkit_codex-3.0.5}/src/pactkit_codex.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""PactKit Codex — thin adapter for OpenAI Codex CLI."""
|
|
2
|
+
|
|
3
|
+
from pactkit import __version__ # noqa: F401
|
|
4
|
+
|
|
5
|
+
# The second import is for its side effect: it registers Codex's enforcement
|
|
6
|
+
# channel (SPEC-4.0-06 R3), and registration IS Core's availability signal —
|
|
7
|
+
# an adapter that is not installed cannot register, so `format == "all"` never
|
|
8
|
+
# installs a channel for a host that never deployed.
|
|
9
|
+
from pactkit_codex.deployer import CodexDeployer # noqa: F401
|
|
10
|
+
from pactkit_codex.enforcement import codex_channel # noqa: F401
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Codex's own declarations (SPEC-4.0-22 Step 6).
|
|
2
|
+
|
|
3
|
+
These lived in Core's transitional seed (`_CORE_CAPABILITIES`, `_CORE_SECURITY`,
|
|
4
|
+
`_CORE_PRETOOL_SURFACES`, `_CORE_DEPLOYMENT_CLAIMS`) until Step 6. While they
|
|
5
|
+
did, this adapter could not answer for itself: `import pactkit_codex` read Core's table
|
|
6
|
+
for its own identity, so removing the seed broke the import rather than the
|
|
7
|
+
deployment.
|
|
8
|
+
|
|
9
|
+
Core still owns the **vocabulary** (`capability.CAPABILITY_VOCABULARY`), the
|
|
10
|
+
four levels, and the hole-filling rule in `resolve_capabilities`. What a given
|
|
11
|
+
Host scores on them is the Host's own declaration — that is the split
|
|
12
|
+
SPEC-4.0-22 R1 draws, and this module is the Codex side of it.
|
|
13
|
+
|
|
14
|
+
Every value here was materialised from the seed Core was carrying, so this is a
|
|
15
|
+
move, not a re-measurement. The `Capability.evidence` strings are preserved
|
|
16
|
+
verbatim for the same reason: they are what the seed said, and re-wording them
|
|
17
|
+
here would quietly change what the registry reports.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pactkit.capability import Capability, CapabilityLevel
|
|
23
|
+
from pactkit.profiles import (
|
|
24
|
+
CLIPolicy,
|
|
25
|
+
DeploymentClaim,
|
|
26
|
+
FormatProfile,
|
|
27
|
+
InputTranslation,
|
|
28
|
+
SecurityDeclaration,
|
|
29
|
+
SecurityFacts,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CAPABILITIES",
|
|
34
|
+
"DEPLOYMENT_CLAIM",
|
|
35
|
+
"INPUT_TRANSLATION",
|
|
36
|
+
"PRETOOL_SURFACE",
|
|
37
|
+
"PROFILE",
|
|
38
|
+
"SECURITY",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cap(name: str, level: CapabilityLevel, implemented: bool, evidence: str) -> Capability:
|
|
43
|
+
return Capability(name=name, level=level, implemented=implemented, evidence=evidence)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: The Host's profile. Core no longer owns it; this module is the source.
|
|
47
|
+
PROFILE = FormatProfile(
|
|
48
|
+
name='codex',
|
|
49
|
+
display_name='Codex CLI',
|
|
50
|
+
global_config_dir='~/.codex',
|
|
51
|
+
project_config_dir='.codex',
|
|
52
|
+
skills_dir='~/.codex/skills',
|
|
53
|
+
agents_dir='~/.codex/agents',
|
|
54
|
+
commands_dir='~/.codex/prompts',
|
|
55
|
+
rules_dir='~/.codex/rules',
|
|
56
|
+
project_instructions_file='AGENTS.md',
|
|
57
|
+
global_instructions_file='AGENTS.md',
|
|
58
|
+
pactkit_yaml_path='.codex/pactkit.yaml',
|
|
59
|
+
agent_format='md',
|
|
60
|
+
rules_import_style='inline',
|
|
61
|
+
excluded_agent_fields=frozenset({'memory', 'permissionMode', 'skills', 'hooks'}),
|
|
62
|
+
excluded_commands=frozenset(),
|
|
63
|
+
supports_model_routing=False,
|
|
64
|
+
skills_path_var='~/.codex/skills',
|
|
65
|
+
command_deploy_form='skill',
|
|
66
|
+
agent_dialect=None,
|
|
67
|
+
command_dialect=None,
|
|
68
|
+
core_bundled=False,
|
|
69
|
+
cli_policy=CLIPolicy.PREFERRED,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
#: What this Host scores on the capability vocabulary.
|
|
73
|
+
CAPABILITIES: dict[str, Capability] = {
|
|
74
|
+
'completion_hook': _cap('completion_hook', CapabilityLevel.DEGRADED, False,
|
|
75
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
76
|
+
"unverified is not unavailable)",
|
|
77
|
+
),
|
|
78
|
+
'git_hook': _cap('git_hook', CapabilityLevel.NATIVE, True, 'shipped (commit_gate pre-commit chain)',
|
|
79
|
+
),
|
|
80
|
+
'global_instruction': _cap('global_instruction', CapabilityLevel.EQUIVALENT, True, 'shipped (~/.codex/AGENTS.md)',
|
|
81
|
+
),
|
|
82
|
+
'native_command': _cap('native_command', CapabilityLevel.DEGRADED, False,
|
|
83
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
84
|
+
"unverified is not unavailable)",
|
|
85
|
+
),
|
|
86
|
+
'native_skill': _cap('native_skill', CapabilityLevel.DEGRADED, False,
|
|
87
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
88
|
+
"unverified is not unavailable)",
|
|
89
|
+
),
|
|
90
|
+
'persistent_session': _cap('persistent_session', CapabilityLevel.DEGRADED, False,
|
|
91
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
92
|
+
"unverified is not unavailable)",
|
|
93
|
+
),
|
|
94
|
+
'post_tool_hook': _cap('post_tool_hook', CapabilityLevel.DEGRADED, False,
|
|
95
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
96
|
+
"unverified is not unavailable)",
|
|
97
|
+
),
|
|
98
|
+
'pre_tool_hook': _cap('pre_tool_hook', CapabilityLevel.NATIVE, True,
|
|
99
|
+
'shipped (doctor.check_codex_hook_capability; native hooks engine)',
|
|
100
|
+
),
|
|
101
|
+
'project_instruction': _cap('project_instruction', CapabilityLevel.EQUIVALENT, True,
|
|
102
|
+
'shipped (AGENTS.md at project root)',
|
|
103
|
+
),
|
|
104
|
+
'rule_import': _cap('rule_import', CapabilityLevel.EQUIVALENT, True, 'shipped as AGENTS.md (generators.deployer)',
|
|
105
|
+
),
|
|
106
|
+
'session_resume': _cap('session_resume', CapabilityLevel.DEGRADED, False,
|
|
107
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
108
|
+
"unverified is not unavailable)",
|
|
109
|
+
),
|
|
110
|
+
'shell_execution': _cap('shell_execution', CapabilityLevel.NATIVE, True, 'shipped (native shell tool)',
|
|
111
|
+
),
|
|
112
|
+
'structured_tool_result': _cap('structured_tool_result', CapabilityLevel.DEGRADED, False,
|
|
113
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
114
|
+
"unverified is not unavailable)",
|
|
115
|
+
),
|
|
116
|
+
'subagent': _cap('subagent', CapabilityLevel.DEGRADED, False,
|
|
117
|
+
"unverified — not measured on this Host; absence of the capability is NOT established (SPEC-4.0-04 R2: "
|
|
118
|
+
"unverified is not unavailable)",
|
|
119
|
+
),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
#: Files a gate must treat specially on this Host (SPEC-4.0-22 R8).
|
|
123
|
+
SECURITY = SecurityDeclaration(
|
|
124
|
+
env_capable=SecurityFacts.declared(['.codex/config.toml']),
|
|
125
|
+
protected=SecurityFacts.declared(['.codex/hooks.json']),
|
|
126
|
+
conditional=SecurityFacts.empty(),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#: Which mechanism this Host offers for pre-tool interception, when one
|
|
130
|
+
#: has been measured. `None` means unverified — not "cannot".
|
|
131
|
+
PRETOOL_SURFACE = 'native hooks engine'
|
|
132
|
+
|
|
133
|
+
#: How this Host executes and where its verification comes from.
|
|
134
|
+
DEPLOYMENT_CLAIM = DeploymentClaim(
|
|
135
|
+
execution_mode='portable',
|
|
136
|
+
verification_source='native_codex_session',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
#: Read-side wire-format data, when the Host has one. `None` means it
|
|
140
|
+
#: declares none — the ladder then never asks it to recognise a payload.
|
|
141
|
+
INPUT_TRANSLATION: InputTranslation | None = None
|
|
@@ -36,13 +36,16 @@ from pactkit.generators.command_ownership import (
|
|
|
36
36
|
write_command_manifest,
|
|
37
37
|
)
|
|
38
38
|
from pactkit.generators.deploy_base import DeployerBase, register_deployer
|
|
39
|
+
|
|
40
|
+
# SPEC-4.0-06 R1: of this adapter's Core imports, only the two from
|
|
41
|
+
# `pactkit.generators.spi` below have a frozen public equivalent; everything
|
|
42
|
+
# else in this block is reached for directly.
|
|
39
43
|
from pactkit.generators.deployer import (
|
|
40
44
|
_cleanup_legacy,
|
|
41
45
|
_cleanup_legacy_portable_methods,
|
|
42
46
|
_enforce_deploy_integrity,
|
|
43
|
-
_render_prompt,
|
|
44
47
|
)
|
|
45
|
-
from pactkit.
|
|
48
|
+
from pactkit.generators.spi import render_prompt, render_skill_md
|
|
46
49
|
from pactkit.prompts.guides import GUIDES_FILES
|
|
47
50
|
from pactkit.prompts.rules import (
|
|
48
51
|
COMMAND_CONDITIONAL_RULES_MAP,
|
|
@@ -53,6 +56,8 @@ from pactkit.prompts.rules import (
|
|
|
53
56
|
)
|
|
54
57
|
from pactkit.utils import atomic_write
|
|
55
58
|
|
|
59
|
+
from pactkit_codex import declarations
|
|
60
|
+
|
|
56
61
|
# All canonical commands deploy. Sprint degrades to sequential execution when
|
|
57
62
|
# Codex does not expose team orchestration primitives.
|
|
58
63
|
CODEX_EXCLUDED_COMMANDS: frozenset[str] = frozenset()
|
|
@@ -107,7 +112,13 @@ class CodexDeployer(DeployerBase):
|
|
|
107
112
|
- config.toml (not JSON/YAML)
|
|
108
113
|
"""
|
|
109
114
|
|
|
110
|
-
|
|
115
|
+
# SPEC-4.0-22 Step 6 — the Host's own declarations (see declarations.py).
|
|
116
|
+
profile = declarations.PROFILE
|
|
117
|
+
capabilities = declarations.CAPABILITIES
|
|
118
|
+
security = declarations.SECURITY
|
|
119
|
+
pretool_surface = declarations.PRETOOL_SURFACE
|
|
120
|
+
deployment_claim = declarations.DEPLOYMENT_CLAIM
|
|
121
|
+
input_translation = declarations.INPUT_TRANSLATION
|
|
111
122
|
|
|
112
123
|
@staticmethod
|
|
113
124
|
def continuation_capabilities(codex_root=None):
|
|
@@ -115,16 +126,17 @@ class CodexDeployer(DeployerBase):
|
|
|
115
126
|
|
|
116
127
|
Codex PDCA commands run in the user's active session. PactKit no
|
|
117
128
|
longer ships runners, workflow hooks, or background execution.
|
|
129
|
+
|
|
130
|
+
The `(execution_mode, verification_source)` pair is deliberately
|
|
131
|
+
**absent**: it is this Host's declaration in `_write_deployment_manifest`,
|
|
132
|
+
and Core derives the manifest from it (SPEC-4.0-06 R8). A copy here would
|
|
133
|
+
not follow the declaration — R8's own test for "copy" not "derived" — and
|
|
134
|
+
the one this adapter carried had drifted from Core's `protocol_version`.
|
|
118
135
|
"""
|
|
119
136
|
del codex_root
|
|
120
137
|
return {
|
|
121
138
|
"finish_guard_supported": False,
|
|
122
139
|
"protocol_version": 0,
|
|
123
|
-
# ``portable`` is the Core protocol guarantee. The separate
|
|
124
|
-
# session_execution field records the host UX without inventing
|
|
125
|
-
# a new doctor/workflow-engine enum value.
|
|
126
|
-
"execution_mode": "portable",
|
|
127
|
-
"verification_source": "native_codex_session",
|
|
128
140
|
"completion_hook": False,
|
|
129
141
|
"session_reentry": False,
|
|
130
142
|
"auto_resume_available": False,
|
|
@@ -283,6 +295,38 @@ class CodexDeployer(DeployerBase):
|
|
|
283
295
|
))
|
|
284
296
|
return tuple(paths)
|
|
285
297
|
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _rule_paths(enabled_commands, enabled_rules) -> dict[str, list[str]]:
|
|
300
|
+
"""Canonical rule id -> every relative path that rule is deployed to.
|
|
301
|
+
|
|
302
|
+
One definition, three readers: `deploy_codex_rules` retires stale
|
|
303
|
+
globals from it, the stale-reference sweep decides what to keep, and
|
|
304
|
+
the deployment manifest declares it to Core (SPEC-4.0-06 R2/AC2).
|
|
305
|
+
Codex keeps global rules in `rules/` and copies conditional ones into
|
|
306
|
+
each applicable command skill; Core derived that from the host name and
|
|
307
|
+
can no longer, and the host-agnostic fallback records none of the
|
|
308
|
+
command-local paths — under-recording the ledger that licenses deletion.
|
|
309
|
+
"""
|
|
310
|
+
rules = sorted(DEFAULT_RULE_IDS) if enabled_rules is None else enabled_rules
|
|
311
|
+
enabled = {
|
|
312
|
+
r for rule_id in rules if (r := normalize_rule_id(rule_id)) is not None
|
|
313
|
+
}
|
|
314
|
+
commands = set(VALID_COMMANDS if enabled_commands is None else enabled_commands)
|
|
315
|
+
declared = {
|
|
316
|
+
rule_id: [f"rules/{RULE_DEFINITIONS[rule_id].filename}"]
|
|
317
|
+
for rule_id in sorted(enabled & RULE_DEFINITIONS.keys())
|
|
318
|
+
if RULE_DEFINITIONS[rule_id].load_policy == "global"
|
|
319
|
+
}
|
|
320
|
+
for command in sorted(COMMAND_CONDITIONAL_RULES_MAP):
|
|
321
|
+
if command not in commands:
|
|
322
|
+
continue
|
|
323
|
+
for rule_id in COMMAND_CONDITIONAL_RULES_MAP[command]:
|
|
324
|
+
if rule_id in enabled:
|
|
325
|
+
declared.setdefault(rule_id, []).append(
|
|
326
|
+
f"skills/{command}/references/rules/{rule_id}.md"
|
|
327
|
+
)
|
|
328
|
+
return declared
|
|
329
|
+
|
|
286
330
|
@staticmethod
|
|
287
331
|
def _cleanup_stale_command_references(
|
|
288
332
|
codex_root: Path, enabled_commands, enabled_rules,
|
|
@@ -312,19 +356,9 @@ class CodexDeployer(DeployerBase):
|
|
|
312
356
|
return
|
|
313
357
|
|
|
314
358
|
commands = set(VALID_COMMANDS if enabled_commands is None else enabled_commands)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
for rule_id in configured
|
|
319
|
-
if (normalized := normalize_rule_id(rule_id)) is not None
|
|
320
|
-
}
|
|
321
|
-
desired = {
|
|
322
|
-
f"skills/{command}/references/rules/{rule_id}.md"
|
|
323
|
-
for command, candidates in COMMAND_CONDITIONAL_RULES_MAP.items()
|
|
324
|
-
if command in commands
|
|
325
|
-
for rule_id in candidates
|
|
326
|
-
if rule_id in rule_ids
|
|
327
|
-
}
|
|
359
|
+
desired = set().union(
|
|
360
|
+
*CodexDeployer._rule_paths(enabled_commands, enabled_rules).values()
|
|
361
|
+
)
|
|
328
362
|
if "project-act" in commands:
|
|
329
363
|
desired.update(
|
|
330
364
|
f"skills/project-act/references/guides/{filename}"
|
|
@@ -365,34 +399,45 @@ class CodexDeployer(DeployerBase):
|
|
|
365
399
|
parent = parent.parent
|
|
366
400
|
|
|
367
401
|
def _write_deployment_manifest(self, codex_root, cfg):
|
|
368
|
-
"""Write the Core manifest
|
|
369
|
-
from pactkit.deploy_manifest import write_deploy_manifest
|
|
370
|
-
|
|
371
|
-
|
|
402
|
+
"""Write the Core manifest, declaring what only this Host knows."""
|
|
403
|
+
from pactkit.deploy_manifest import HostDeclaration, write_deploy_manifest
|
|
404
|
+
|
|
405
|
+
# Codex's rule layout is not derivable from the host name any more
|
|
406
|
+
# (SPEC-4.0-06 R2). Core used to branch on `format_name == "codex"` to
|
|
407
|
+
# find command-local rule references; the host-agnostic fallback records
|
|
408
|
+
# only `rules/pactkit-runtime.md`, under-recording the ledger that is
|
|
409
|
+
# what licenses `_cleanup_stale_command_references` to delete.
|
|
410
|
+
# R8: the `(execution_mode, verification_source)` pair is the Host's
|
|
411
|
+
# **declaration** — Core records it and derives both sections from it,
|
|
412
|
+
# including `tool_execution`, `manual_resume` and `guarantee_level`,
|
|
413
|
+
# which are functions of `execution_mode` and not separate facts. This
|
|
414
|
+
# adapter used to write both sections itself, restating the same two
|
|
415
|
+
# values; they happened to agree, and nothing kept them agreeing.
|
|
416
|
+
manifest_path = write_deploy_manifest(
|
|
417
|
+
codex_root, "codex", cfg,
|
|
418
|
+
declaration=HostDeclaration(
|
|
419
|
+
rule_paths=CodexDeployer._rule_paths(
|
|
420
|
+
cfg.get("commands"), cfg.get("rules", sorted(DEFAULT_RULE_IDS)),
|
|
421
|
+
),
|
|
422
|
+
execution_mode="portable",
|
|
423
|
+
verification_source="native_codex_session",
|
|
424
|
+
),
|
|
425
|
+
)
|
|
372
426
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
373
427
|
capability = self.continuation_capabilities(codex_root)
|
|
374
428
|
if not capability["hook_installed"]:
|
|
375
429
|
capability["hook_observed"] = False
|
|
376
430
|
capability["hook_trusted"] = False
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
"
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"thread_resume": False,
|
|
388
|
-
"turn_steer": False,
|
|
389
|
-
"background_execution": False,
|
|
390
|
-
"cancellation": False,
|
|
391
|
-
"e2e_validated": False,
|
|
392
|
-
"execution_mode": "portable",
|
|
393
|
-
"manual_resume": False,
|
|
394
|
-
"session_execution": "native_current_session",
|
|
395
|
-
}
|
|
431
|
+
# Add only what Core cannot know; restating the pair here would
|
|
432
|
+
# re-create the second definition point R8 removes. The sections take
|
|
433
|
+
# different extras — hook state, and the session model's name.
|
|
434
|
+
derived = ("execution_mode", "verification_source", "guarantee_level")
|
|
435
|
+
manifest["workflow_continuation"].update({
|
|
436
|
+
key: value
|
|
437
|
+
for key, value in capability.items()
|
|
438
|
+
if key not in derived and key not in manifest["workflow_continuation"]
|
|
439
|
+
})
|
|
440
|
+
manifest["host_capabilities"]["session_execution"] = capability["session_execution"]
|
|
396
441
|
atomic_write(
|
|
397
442
|
manifest_path,
|
|
398
443
|
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
|
|
@@ -550,10 +595,13 @@ class CodexDeployer(DeployerBase):
|
|
|
550
595
|
|
|
551
596
|
# Selective deploys describe the desired projection. Remove a disabled
|
|
552
597
|
# rule only when the prior manifest proves ownership of unchanged bytes.
|
|
598
|
+
# Only `rules/` is this method's business; both readers take the layout
|
|
599
|
+
# from `_rule_paths`.
|
|
553
600
|
enabled_paths = {
|
|
554
|
-
|
|
555
|
-
for
|
|
556
|
-
|
|
601
|
+
path
|
|
602
|
+
for paths in CodexDeployer._rule_paths(None, enabled_rules).values()
|
|
603
|
+
for path in paths
|
|
604
|
+
if path.startswith("rules/")
|
|
557
605
|
}
|
|
558
606
|
for definition in RULE_DEFINITIONS.values():
|
|
559
607
|
destination = rules_dir / definition.filename
|
|
@@ -597,7 +645,7 @@ class CodexDeployer(DeployerBase):
|
|
|
597
645
|
if not content:
|
|
598
646
|
continue
|
|
599
647
|
content = content.strip()
|
|
600
|
-
content =
|
|
648
|
+
content = render_prompt(content, profile)
|
|
601
649
|
content = CodexDeployer.strip_model_selection_table(content)
|
|
602
650
|
for pattern in CLAUDE_PATH_PATTERNS:
|
|
603
651
|
content = content.replace(pattern, "~/.codex/")
|
|
@@ -647,7 +695,7 @@ class CodexDeployer(DeployerBase):
|
|
|
647
695
|
lines = [f"# PactKit Runtime Contract (v{__version__})", ""]
|
|
648
696
|
if "runtime" in enabled_rule_ids:
|
|
649
697
|
lines.extend((
|
|
650
|
-
|
|
698
|
+
render_prompt(RULE_DEFINITIONS["runtime"].content.strip(), profile),
|
|
651
699
|
"",
|
|
652
700
|
))
|
|
653
701
|
lines.extend((
|
|
@@ -656,7 +704,7 @@ class CodexDeployer(DeployerBase):
|
|
|
656
704
|
"",
|
|
657
705
|
))
|
|
658
706
|
|
|
659
|
-
raw_content =
|
|
707
|
+
raw_content = render_prompt("\n".join(lines), profile)
|
|
660
708
|
|
|
661
709
|
for pattern in CLAUDE_PATH_PATTERNS:
|
|
662
710
|
raw_content = raw_content.replace(pattern, "~/.codex/")
|
|
@@ -730,7 +778,7 @@ class CodexDeployer(DeployerBase):
|
|
|
730
778
|
description = line.split(":", 1)[1].strip().strip('"')
|
|
731
779
|
content = parts[2].lstrip("\n")
|
|
732
780
|
|
|
733
|
-
content =
|
|
781
|
+
content = render_prompt(content, profile)
|
|
734
782
|
if cmd_name == "project-sprint":
|
|
735
783
|
description = "Sequential PDCA in the current Codex session"
|
|
736
784
|
content = _codex_single_session_sprint()
|
|
@@ -774,7 +822,7 @@ class CodexDeployer(DeployerBase):
|
|
|
774
822
|
continue
|
|
775
823
|
elif key in RULE_DEFINITIONS:
|
|
776
824
|
inline_rules.append(
|
|
777
|
-
|
|
825
|
+
render_prompt(RULE_DEFINITIONS[key].content.strip(), profile)
|
|
778
826
|
)
|
|
779
827
|
|
|
780
828
|
conditional = {
|
|
@@ -807,7 +855,7 @@ class CodexDeployer(DeployerBase):
|
|
|
807
855
|
rendered[cmd_name] = skill_content
|
|
808
856
|
command_references = {}
|
|
809
857
|
for rule_id, definition in conditional.items():
|
|
810
|
-
reference_content =
|
|
858
|
+
reference_content = render_prompt(
|
|
811
859
|
definition.content.strip(), profile,
|
|
812
860
|
).replace(
|
|
813
861
|
"~/.codex/skills/_rules/guides/", "../guides/",
|
|
@@ -999,7 +1047,6 @@ class CodexDeployer(DeployerBase):
|
|
|
999
1047
|
# STORY-slim-139 R4: consume the core SKILL_MANIFEST — no local
|
|
1000
1048
|
# hardcoded skill list (the old 10-item snapshot silently dropped
|
|
1001
1049
|
# garden/audit/report when core added them).
|
|
1002
|
-
from pactkit.generators.deployer import _render_skill_md
|
|
1003
1050
|
from pactkit.prompts.skills import get_skill_manifest
|
|
1004
1051
|
|
|
1005
1052
|
enabled_set = set(enabled_skills)
|
|
@@ -1011,7 +1058,7 @@ class CodexDeployer(DeployerBase):
|
|
|
1011
1058
|
skill_dir = skills_dir / sd["name"]
|
|
1012
1059
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
1013
1060
|
|
|
1014
|
-
skill_md =
|
|
1061
|
+
skill_md = render_skill_md(sd, profile, _prefix)
|
|
1015
1062
|
skill_md = _replace_cli_with_scripts(skill_md)
|
|
1016
1063
|
skill_md = CodexDeployer.strip_model_references(skill_md)
|
|
1017
1064
|
_enforce_deploy_integrity(skill_md, profile, f"skill:{sd['name']}")
|
|
@@ -1200,7 +1247,8 @@ normal verification evidence.
|
|
|
1200
1247
|
# CLI subcommands that map to skill scripts (STORY-slim-145 R3: lossy CLI
|
|
1201
1248
|
# prefix replacements for regression/lint/context/clean/visualize/guard/
|
|
1202
1249
|
# doctor/update REMOVED — Codex is CLIPolicy.PREFERRED, so canonical `pactkit`
|
|
1203
|
-
# CLI commands are preserved by Core
|
|
1250
|
+
# CLI commands are preserved by Core's render_prompt (via the adapter SPI)
|
|
1251
|
+
# rather than rewritten here.
|
|
1204
1252
|
# Only genuine path normalization (board/scaffold) remains in this table.
|
|
1205
1253
|
_CLI_TO_SCRIPT = [
|
|
1206
1254
|
# board (path normalization)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Codex's enforcement channel, stated by its owner (SPEC-4.0-06 R3).
|
|
2
|
+
|
|
3
|
+
Core used to install this channel from a ``format_name == "codex"`` branch:
|
|
4
|
+
the config file, the matchers, the trust notice and the transport key were
|
|
5
|
+
Core constants. R3 moved all four onto the host, and Core now traverses a
|
|
6
|
+
registry instead of branching. The transport key has since been deleted
|
|
7
|
+
outright rather than moved — see ``codex_channel``. This module is what makes "the codex adapter
|
|
8
|
+
is installed" and "Codex's channel can be installed" the same fact — the
|
|
9
|
+
registry's own docstring names import-time registration as the availability
|
|
10
|
+
signal, so nothing here asks Core whether this host is present.
|
|
11
|
+
|
|
12
|
+
Registering nothing is a valid state and means the channel is not installed:
|
|
13
|
+
that is the honest answer for a host whose adapter is absent, and it is why
|
|
14
|
+
``gate_matrix.channels_for`` must not be read as an install guarantee on its
|
|
15
|
+
own.
|
|
16
|
+
|
|
17
|
+
The merge, the config lock, the atomic replace and the "unrecognized
|
|
18
|
+
structure → leave byte-identical" protections are Core's ``install_channel``.
|
|
19
|
+
Nothing here re-implements them; this module supplies host facts and nothing
|
|
20
|
+
else.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from pactkit.commit_gate import HookChannel, register_enforcement_channel
|
|
28
|
+
|
|
29
|
+
# MUST equal the string `gate_matrix.channels_for("codex")` advertises, or the
|
|
30
|
+
# coverage notice names a channel that is not the one installed — the
|
|
31
|
+
# divergence SPEC-4.0-06 R4 records as this dataclass's standing tension.
|
|
32
|
+
# `test_codex_enforcement_channel.py` asserts the agreement; nothing can
|
|
33
|
+
# enforce it structurally, which is exactly why it is asserted.
|
|
34
|
+
IDENTITY = "codex PreToolUse hook"
|
|
35
|
+
|
|
36
|
+
# The wire contract is Codex's, and it matches Claude Code's (tool_name
|
|
37
|
+
# "Bash", exit 2 + stderr block), so the handler is `pactkit commit-gate
|
|
38
|
+
# --hook` unchanged. Bash feeds the commit/push/tamper pipeline; Edit|Write
|
|
39
|
+
# feeds the tamper guard. Stated here rather than imported from Core: which
|
|
40
|
+
# tools a host gates is host knowledge, and importing Core's tuple would put
|
|
41
|
+
# it back.
|
|
42
|
+
MATCHERS = ("Bash", "Edit|Write")
|
|
43
|
+
|
|
44
|
+
# Deployment never activates the hook silently — Codex requires a one-time
|
|
45
|
+
# content-hash trust confirmation, and the report has to say so.
|
|
46
|
+
TRUST_NOTICE = (
|
|
47
|
+
" — needs one-time trust confirmation inside Codex "
|
|
48
|
+
"(content-hash prompt) before it takes effect"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def codex_channel(root: Path) -> HookChannel:
|
|
53
|
+
"""Codex's channel as of this deploy root.
|
|
54
|
+
|
|
55
|
+
No ``transport`` is declared: the retired MCP transport is gone, so this
|
|
56
|
+
host's transport is the shell hook, which is ``HookChannel``'s default.
|
|
57
|
+
|
|
58
|
+
The config key that used to select it (``enforcement.codex_hook_transport``)
|
|
59
|
+
was the last Policy-layer remnant of the MCP retirement and was deleted on
|
|
60
|
+
2026-09-22 — Core never owned it (§4 P1.1 Policy #2: which key selects a
|
|
61
|
+
host's load policy is host knowledge) and this adapter only read it to
|
|
62
|
+
RECOGNIZE a pending migration. That recognition was not load-bearing: the
|
|
63
|
+
installer already decides from the hooks file itself, and the key's only
|
|
64
|
+
remaining effect was to report a migration on projects that had nothing to
|
|
65
|
+
migrate.
|
|
66
|
+
"""
|
|
67
|
+
del root
|
|
68
|
+
return HookChannel(
|
|
69
|
+
relative_path=".codex/hooks.json",
|
|
70
|
+
identity=IDENTITY,
|
|
71
|
+
matchers=MATCHERS,
|
|
72
|
+
label="codex hook",
|
|
73
|
+
notice=TRUST_NOTICE,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
register_enforcement_channel("codex", codex_channel)
|
|
@@ -2,7 +2,9 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/pactkit_codex/__init__.py
|
|
5
|
+
src/pactkit_codex/declarations.py
|
|
5
6
|
src/pactkit_codex/deployer.py
|
|
7
|
+
src/pactkit_codex/enforcement.py
|
|
6
8
|
src/pactkit_codex.egg-info/PKG-INFO
|
|
7
9
|
src/pactkit_codex.egg-info/SOURCES.txt
|
|
8
10
|
src/pactkit_codex.egg-info/dependency_links.txt
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""This host's deployed prompt bodies, pinned against a golden (R7/AC10).
|
|
2
|
+
|
|
3
|
+
2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
|
|
4
|
+
left adapter-host body comparison out of CI — the core workflow compares
|
|
5
|
+
classic only. This suite pins the CODEX deployment's prompt bodies:
|
|
6
|
+
|
|
7
|
+
- digest drift (a rule edited or deleted inside a prompt body) fails,
|
|
8
|
+
naming the file;
|
|
9
|
+
- a core version mismatch fails with an explicit action — regenerate the
|
|
10
|
+
golden against the new core, or publish the matching core release
|
|
11
|
+
(PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
|
|
12
|
+
- a drift at a MATCHING version is diagnosed as such rather than as a
|
|
13
|
+
version skew (`_drift_diagnosis`) — the version guard compares strings and
|
|
14
|
+
so cannot see a core prompt edit made inside one version;
|
|
15
|
+
- a probe test deploys, edits one body in place, and proves the SAME
|
|
16
|
+
comparison logic flags it (删改必被检出, not vacuous).
|
|
17
|
+
|
|
18
|
+
Regenerate with: tests/scripts/refresh_host_body_golden.py — and record the
|
|
19
|
+
cause when you do; an unexplained regeneration turns the golden into a copy
|
|
20
|
+
of today's output and its guard value goes to zero.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import json
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
|
|
33
|
+
HOST = "codex"
|
|
34
|
+
GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
|
|
35
|
+
DEPLOY_SUFFIXES = (".md", ".json", ".toml")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _core_version() -> str:
|
|
39
|
+
import importlib.metadata as m
|
|
40
|
+
|
|
41
|
+
return m.version("pactkit")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize(content: str, deploy_root: str) -> str:
|
|
45
|
+
"""Strip only the deterministic environment difference: the deploy root
|
|
46
|
+
path baked into rendered prompts. Rule text stays — normalizing it would
|
|
47
|
+
swallow the drift this suite exists to catch."""
|
|
48
|
+
return content.replace(deploy_root, "<DEPLOY_ROOT>")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
|
|
52
|
+
home = tmp_path / "home"
|
|
53
|
+
target = tmp_path / "deploy"
|
|
54
|
+
home.mkdir()
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
|
|
57
|
+
capture_output=True, text=True, timeout=180,
|
|
58
|
+
env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
|
|
59
|
+
)
|
|
60
|
+
if proc.returncode != 0:
|
|
61
|
+
return None, proc
|
|
62
|
+
digests: dict[str, str] = {}
|
|
63
|
+
for path in sorted(target.rglob("*")):
|
|
64
|
+
if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
|
|
65
|
+
continue
|
|
66
|
+
body = _normalize(path.read_text(encoding="utf-8"), str(target))
|
|
67
|
+
digests[path.relative_to(target).as_posix()] = hashlib.sha256(
|
|
68
|
+
body.encode("utf-8")
|
|
69
|
+
).hexdigest()
|
|
70
|
+
return digests, proc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _drift_diagnosis(golden: dict, core_version: str) -> str:
|
|
74
|
+
"""Which of the two red lights is this? R7's answer to the version guard's
|
|
75
|
+
structural blindness.
|
|
76
|
+
|
|
77
|
+
The guard compares version STRINGS, so "core edited a prompt without
|
|
78
|
+
bumping the version" and "the adapter broke" produce the same diff — and
|
|
79
|
+
the old message resolved that ambiguity by hedging, offering "publish the
|
|
80
|
+
matching core release" as an alternative to regenerating. In a path only
|
|
81
|
+
reachable once the versions already match, that advice cannot apply: it
|
|
82
|
+
was describing the other red light. So the diagnosis is derived from the
|
|
83
|
+
version facts instead of guessed, and names the one thing the evidence
|
|
84
|
+
establishes — that a version skew is ruled out.
|
|
85
|
+
|
|
86
|
+
What it does NOT claim: that the adapter is innocent. Matching versions
|
|
87
|
+
plus differing bodies means the difference is content, and content can
|
|
88
|
+
have moved on either side. Both diffs are named, because guessing between
|
|
89
|
+
them is what made this message useless.
|
|
90
|
+
|
|
91
|
+
Measured instance (2026-09-21, Batch 4) — three bodies drifted with the
|
|
92
|
+
versions matching. Attributed by deploying the SAME adapter against core
|
|
93
|
+
v3.0.3 and against the working tree:
|
|
94
|
+
|
|
95
|
+
- `skills/project-act/SKILL.md` — core `49ef848` added `--outcome green`
|
|
96
|
+
and the unavailable/red branches to the Act playbook's "Verification
|
|
97
|
+
Record" line (`prompts/commands.py:282`), after this golden was
|
|
98
|
+
regenerated and without a version bump. That commit's own message
|
|
99
|
+
predicted this drift and deferred it to Batch 5.
|
|
100
|
+
- `skills/.pactkit-command-manifest.json` — the sha256 of the body above,
|
|
101
|
+
so a consequence of the same edit rather than an independent drift.
|
|
102
|
+
- `.pactkit-deployed.json` — core `04b1689` deleted the `rule_loading.
|
|
103
|
+
layout` host-policy string, and the rule records changed under
|
|
104
|
+
SPEC-4.0-06 R2. No prompt body is involved.
|
|
105
|
+
|
|
106
|
+
Not a cause, though it looks like one: `04b1689` also edited
|
|
107
|
+
`prompts/rules.py`, but that line sits in the legacy `architecture`
|
|
108
|
+
module, which this host does not deploy — every deployed body is
|
|
109
|
+
byte-identical across the two cores, so it drifted nothing. A plausible
|
|
110
|
+
cause that the evidence rules out is worth as much as one it confirms.
|
|
111
|
+
|
|
112
|
+
Measured instance (2026-09-22, SPEC-4.0-06 R8) — one body drifted with the
|
|
113
|
+
versions matching, and this time **the adapter moved, not Core**:
|
|
114
|
+
|
|
115
|
+
- `.pactkit-deployed.json` — `_write_deployment_manifest` now declares the
|
|
116
|
+
`(execution_mode, verification_source)` pair to Core instead of writing
|
|
117
|
+
both manifest sections itself. Three consequences, all inside that one
|
|
118
|
+
artifact: `protocol_version` 0 -> 1 in both sections (Core's
|
|
119
|
+
`CORE_PROTOCOL_VERSION` — the adapter's hardcoded 0 had drifted from it
|
|
120
|
+
with nothing keeping the two equal, which is the defect R8 removes),
|
|
121
|
+
`workflow_continuation.verification_source` is no longer restated, and
|
|
122
|
+
`workflow_continuation.e2e_validated` is now Core's.
|
|
123
|
+
|
|
124
|
+
The other 67 bodies are byte-identical, and that is the evidence the move
|
|
125
|
+
is adapter-side: a Core prompt edit would have moved prompt bodies, and
|
|
126
|
+
none moved.
|
|
127
|
+
"""
|
|
128
|
+
if golden["core_version"] == core_version:
|
|
129
|
+
return (
|
|
130
|
+
f"core_version matches but content differs — the golden and this "
|
|
131
|
+
f"environment are both core {core_version}, so a version skew is "
|
|
132
|
+
f"ruled out and the difference is content: either Core's prompts "
|
|
133
|
+
f"changed inside this version (no bump, so the check above cannot "
|
|
134
|
+
f"see it — measured instance: core 49ef848 edited the Act playbook "
|
|
135
|
+
f"after this golden was regenerated) or this adapter's rendering "
|
|
136
|
+
f"changed. Diff the core prompts and this adapter since the golden "
|
|
137
|
+
f"was generated; regenerate only once the cause is known — see "
|
|
138
|
+
f"this function's docstring for the 2026-09-21 attribution"
|
|
139
|
+
)
|
|
140
|
+
return (
|
|
141
|
+
f"core_version differs — the golden was generated against core "
|
|
142
|
+
f"{golden['core_version']} and this environment has {core_version}. "
|
|
143
|
+
f"Regenerate the golden after the core change, or publish the matching "
|
|
144
|
+
f"core release first (PENDING-COMPATIBLE-CORE, spec R8 release-window "
|
|
145
|
+
f"semantics)"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _assert_no_drift(golden: dict, live: dict[str, str]) -> None:
|
|
150
|
+
"""The one comparison both the golden test and the tamper probe use —
|
|
151
|
+
including the diagnosis, so the probe exercises the message a real drift
|
|
152
|
+
would produce rather than a similar one."""
|
|
153
|
+
golden_digests = golden["body_digests"]
|
|
154
|
+
changed = sorted(
|
|
155
|
+
k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
|
|
156
|
+
)
|
|
157
|
+
removed = sorted(set(golden_digests) - set(live))
|
|
158
|
+
added = sorted(set(live) - set(golden_digests))
|
|
159
|
+
assert not (changed or removed or added), (
|
|
160
|
+
f"prompt body drift: changed={changed} removed={removed} added={added} "
|
|
161
|
+
f"— {_drift_diagnosis(golden, _core_version())}"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestHostBodySnapshot:
|
|
166
|
+
def test_bodies_match_the_golden(self, tmp_path):
|
|
167
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
168
|
+
core_v = _core_version()
|
|
169
|
+
if golden["core_version"] != core_v:
|
|
170
|
+
# Fail here rather than at the comparison: under a version skew
|
|
171
|
+
# every body reads as "changed", which is noise, not signal. The
|
|
172
|
+
# wording comes from the same diagnosis function the comparison
|
|
173
|
+
# uses, so the two red lights cannot drift apart.
|
|
174
|
+
pytest.fail(_drift_diagnosis(golden, core_v))
|
|
175
|
+
live, proc = _deployed_digests(tmp_path)
|
|
176
|
+
assert live is not None, (
|
|
177
|
+
f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
|
|
178
|
+
)
|
|
179
|
+
_assert_no_drift(golden, live)
|
|
180
|
+
|
|
181
|
+
def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
|
|
182
|
+
"""Non-vacuity, end to end: deploy, edit one prompt body in place,
|
|
183
|
+
and the SAME comparison that guards the golden must flag it."""
|
|
184
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
185
|
+
live, proc = _deployed_digests(tmp_path)
|
|
186
|
+
assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
|
|
187
|
+
target = tmp_path / "deploy"
|
|
188
|
+
# Pick a real deployed prompt body and delete one rule line from it.
|
|
189
|
+
victims = sorted(
|
|
190
|
+
p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
|
|
191
|
+
)
|
|
192
|
+
assert victims, "no deployed .md body found to tamper with"
|
|
193
|
+
victim = victims[0]
|
|
194
|
+
rel = victim.relative_to(target).as_posix()
|
|
195
|
+
body = victim.read_text(encoding="utf-8")
|
|
196
|
+
victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
|
|
197
|
+
|
|
198
|
+
tampered = {
|
|
199
|
+
p.relative_to(target).as_posix(): hashlib.sha256(
|
|
200
|
+
_normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
|
|
201
|
+
).hexdigest()
|
|
202
|
+
for p in sorted(target.rglob("*"))
|
|
203
|
+
if p.is_file() and p.suffix in DEPLOY_SUFFIXES
|
|
204
|
+
}
|
|
205
|
+
with pytest.raises(AssertionError, match=rel):
|
|
206
|
+
_assert_no_drift(golden, tampered)
|
|
207
|
+
|
|
208
|
+
def test_a_drift_at_a_matching_version_is_diagnosed_as_content(self, tmp_path):
|
|
209
|
+
"""R7(b): on drift the guard must say the versions matched. The old
|
|
210
|
+
message offered "publish the matching core release" as an alternative
|
|
211
|
+
to regenerating — advice that cannot apply here, because this path is
|
|
212
|
+
only reachable once the versions already match."""
|
|
213
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
214
|
+
assert golden["core_version"] == _core_version(), (
|
|
215
|
+
"this test is about a drift at a MATCHING version"
|
|
216
|
+
)
|
|
217
|
+
live, proc = _deployed_digests(tmp_path)
|
|
218
|
+
assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
|
|
219
|
+
victim = sorted(golden["body_digests"])[0]
|
|
220
|
+
drifted = dict(live)
|
|
221
|
+
drifted[victim] = hashlib.sha256(b"not the deployed body").hexdigest()
|
|
222
|
+
|
|
223
|
+
with pytest.raises(AssertionError) as caught:
|
|
224
|
+
_assert_no_drift(golden, drifted)
|
|
225
|
+
|
|
226
|
+
message = str(caught.value)
|
|
227
|
+
assert victim in message, "the drifting body is not named"
|
|
228
|
+
assert "core_version matches but content differs" in message, (
|
|
229
|
+
f"the guard did not diagnose the matching version: {message}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def test_a_version_skew_is_diagnosed_as_a_skew(self):
|
|
233
|
+
"""The other red light, from the same function: a golden pinned against
|
|
234
|
+
a different core must be told to regenerate or publish — not told its
|
|
235
|
+
content changed inside a version it does not share."""
|
|
236
|
+
golden = {"core_version": "0.0.0-not-this-core", "body_digests": {}}
|
|
237
|
+
message = _drift_diagnosis(golden, _core_version())
|
|
238
|
+
assert "PENDING-COMPATIBLE-CORE" in message
|
|
239
|
+
assert "core_version matches but content differs" not in message
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
"""This host's deployed prompt bodies, pinned against a golden (R7/AC10).
|
|
2
|
-
|
|
3
|
-
2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
|
|
4
|
-
left adapter-host body comparison out of CI — the core workflow compares
|
|
5
|
-
classic only. This suite pins the CODEX deployment's prompt bodies:
|
|
6
|
-
|
|
7
|
-
- digest drift (a rule edited or deleted inside a prompt body) fails,
|
|
8
|
-
naming the file;
|
|
9
|
-
- a core version mismatch fails with an explicit action — regenerate the
|
|
10
|
-
golden against the new core, or publish the matching core release
|
|
11
|
-
(PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
|
|
12
|
-
- a probe test deploys, edits one body in place, and proves the SAME
|
|
13
|
-
comparison logic flags it (删改必被检出, not vacuous).
|
|
14
|
-
|
|
15
|
-
Regenerate with: tests/scripts/refresh_host_body_golden.py
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
from __future__ import annotations
|
|
19
|
-
|
|
20
|
-
import hashlib
|
|
21
|
-
import json
|
|
22
|
-
import subprocess
|
|
23
|
-
import sys
|
|
24
|
-
from pathlib import Path
|
|
25
|
-
|
|
26
|
-
import pytest
|
|
27
|
-
|
|
28
|
-
HOST = "codex"
|
|
29
|
-
GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
|
|
30
|
-
DEPLOY_SUFFIXES = (".md", ".json", ".toml")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _core_version() -> str:
|
|
34
|
-
import importlib.metadata as m
|
|
35
|
-
|
|
36
|
-
return m.version("pactkit")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _normalize(content: str, deploy_root: str) -> str:
|
|
40
|
-
"""Strip only the deterministic environment difference: the deploy root
|
|
41
|
-
path baked into rendered prompts. Rule text stays — normalizing it would
|
|
42
|
-
swallow the drift this suite exists to catch."""
|
|
43
|
-
return content.replace(deploy_root, "<DEPLOY_ROOT>")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
|
|
47
|
-
home = tmp_path / "home"
|
|
48
|
-
target = tmp_path / "deploy"
|
|
49
|
-
home.mkdir()
|
|
50
|
-
proc = subprocess.run(
|
|
51
|
-
[sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
|
|
52
|
-
capture_output=True, text=True, timeout=180,
|
|
53
|
-
env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
|
|
54
|
-
)
|
|
55
|
-
if proc.returncode != 0:
|
|
56
|
-
return None, proc
|
|
57
|
-
digests: dict[str, str] = {}
|
|
58
|
-
for path in sorted(target.rglob("*")):
|
|
59
|
-
if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
|
|
60
|
-
continue
|
|
61
|
-
body = _normalize(path.read_text(encoding="utf-8"), str(target))
|
|
62
|
-
digests[path.relative_to(target).as_posix()] = hashlib.sha256(
|
|
63
|
-
body.encode("utf-8")
|
|
64
|
-
).hexdigest()
|
|
65
|
-
return digests, proc
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _assert_no_drift(golden_digests: dict[str, str], live: dict[str, str]) -> None:
|
|
69
|
-
"""The one comparison both the golden test and the tamper probe use."""
|
|
70
|
-
changed = sorted(
|
|
71
|
-
k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
|
|
72
|
-
)
|
|
73
|
-
removed = sorted(set(golden_digests) - set(live))
|
|
74
|
-
added = sorted(set(live) - set(golden_digests))
|
|
75
|
-
assert not (changed or removed or added), (
|
|
76
|
-
f"prompt body drift: changed={changed} removed={removed} added={added} "
|
|
77
|
-
f"— if the core prompts changed, regenerate the golden "
|
|
78
|
-
f"(tests/scripts/refresh_host_body_golden.py) or publish the matching "
|
|
79
|
-
f"core release first (PENDING-COMPATIBLE-CORE)"
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class TestHostBodySnapshot:
|
|
84
|
-
def test_bodies_match_the_golden(self, tmp_path):
|
|
85
|
-
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
86
|
-
core_v = _core_version()
|
|
87
|
-
if golden["core_version"] != core_v:
|
|
88
|
-
pytest.fail(
|
|
89
|
-
f"PENDING-COMPATIBLE-CORE: the {HOST} golden was generated "
|
|
90
|
-
f"against core {golden['core_version']}, this environment has "
|
|
91
|
-
f"{core_v}. Either regenerate it after the core change "
|
|
92
|
-
f"(tests/scripts/refresh_host_body_golden.py) or publish the "
|
|
93
|
-
f"matching core release first (spec R8 release-window semantics)."
|
|
94
|
-
)
|
|
95
|
-
live, proc = _deployed_digests(tmp_path)
|
|
96
|
-
assert live is not None, (
|
|
97
|
-
f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
|
|
98
|
-
)
|
|
99
|
-
_assert_no_drift(golden["body_digests"], live)
|
|
100
|
-
|
|
101
|
-
def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
|
|
102
|
-
"""Non-vacuity, end to end: deploy, edit one prompt body in place,
|
|
103
|
-
and the SAME comparison that guards the golden must flag it."""
|
|
104
|
-
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
105
|
-
live, proc = _deployed_digests(tmp_path)
|
|
106
|
-
assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
|
|
107
|
-
target = tmp_path / "deploy"
|
|
108
|
-
# Pick a real deployed prompt body and delete one rule line from it.
|
|
109
|
-
victims = sorted(
|
|
110
|
-
p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
|
|
111
|
-
)
|
|
112
|
-
assert victims, "no deployed .md body found to tamper with"
|
|
113
|
-
victim = victims[0]
|
|
114
|
-
rel = victim.relative_to(target).as_posix()
|
|
115
|
-
body = victim.read_text(encoding="utf-8")
|
|
116
|
-
victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
|
|
117
|
-
|
|
118
|
-
tampered = {
|
|
119
|
-
p.relative_to(target).as_posix(): hashlib.sha256(
|
|
120
|
-
_normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
|
|
121
|
-
).hexdigest()
|
|
122
|
-
for p in sorted(target.rglob("*"))
|
|
123
|
-
if p.is_file() and p.suffix in DEPLOY_SUFFIXES
|
|
124
|
-
}
|
|
125
|
-
with pytest.raises(AssertionError, match=rel):
|
|
126
|
-
_assert_no_drift(golden["body_digests"], tampered)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|