plan-auditor 2.4.4__tar.gz → 2.4.6__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.

Potentially problematic release.


This version of plan-auditor might be problematic. Click here for more details.

Files changed (116) hide show
  1. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/CHANGELOG.md +25 -0
  2. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/CITATION.cff +2 -2
  3. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/PKG-INFO +1 -1
  4. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/SECURITY.md +10 -3
  5. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/SKILL.md +1 -1
  6. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/deployment-isolation.md +49 -5
  7. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/threat-model.md +12 -1
  8. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/package.json +1 -1
  9. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/pyproject.toml +1 -1
  10. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/audit_check.py +6 -8
  11. plan_auditor-2.4.6/scripts/check_isolation.py +116 -0
  12. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/__init__.py +1 -1
  13. plan_auditor-2.4.6/tests/test_linux_check_os_isolation.py +150 -0
  14. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/.gitignore +0 -0
  15. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/CODE_OF_CONDUCT.md +0 -0
  16. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/CONTRIBUTING.md +0 -0
  17. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/LICENSE +0 -0
  18. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/README.md +0 -0
  19. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/launcher_contract.test.js +0 -0
  20. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/plan-auditor.js +0 -0
  21. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/prepublish_gate.js +0 -0
  22. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/prepublish_gate_contract.test.js +0 -0
  23. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/prepublish_lifecycle.test.js +0 -0
  24. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/bin/publish_mode_contract.test.js +0 -0
  25. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/architecture.md +0 -0
  26. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/benchmark.md +0 -0
  27. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/check_attestation_claims.py +0 -0
  28. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/check_readme_size.py +0 -0
  29. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/cli.md +0 -0
  30. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/dependency-graph.md +0 -0
  31. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/formal-planning.md +0 -0
  32. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/index.md +0 -0
  33. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/integrations.md +0 -0
  34. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/quickstart.md +0 -0
  35. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/readme_size_baseline.json +0 -0
  36. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/release-status.md +0 -0
  37. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/docs/workspace-artifacts.md +0 -0
  38. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/examples/fib/.plan-auditor/plan.json +0 -0
  39. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/examples/fib/fib.py +0 -0
  40. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/examples/fib/request-source.json +0 -0
  41. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/examples/fib/test_fib.py +0 -0
  42. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/hooks/README.md +0 -0
  43. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/hooks/gate_hook.py +0 -0
  44. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/index.js +0 -0
  45. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/mkdocs.yml +0 -0
  46. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/references/plan-format.md +0 -0
  47. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/__init__.py +0 -0
  48. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/contract.py +0 -0
  49. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/exec_trust.py +0 -0
  50. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/integrity.py +0 -0
  51. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/plan_graph.py +0 -0
  52. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/scripts/stop_gate.py +0 -0
  53. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/adversarial.py +0 -0
  54. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/agents.py +0 -0
  55. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/agents_hardening.py +0 -0
  56. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/audit_session.py +0 -0
  57. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/cli.py +0 -0
  58. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/config.py +0 -0
  59. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/contracts.py +0 -0
  60. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/control_plane.py +0 -0
  61. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/coverage.py +0 -0
  62. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/daemon.py +0 -0
  63. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/events.py +0 -0
  64. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/evidence.py +0 -0
  65. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/formal_compiler.py +0 -0
  66. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/formal_planning.py +0 -0
  67. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/formal_semantics.py +0 -0
  68. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/gate.py +0 -0
  69. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/goals.py +0 -0
  70. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/integrity.py +0 -0
  71. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/lifecycle.py +0 -0
  72. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/lock_hardening.py +0 -0
  73. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/orchestrator.py +0 -0
  74. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/plan_verifier.py +0 -0
  75. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/plans.py +0 -0
  76. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/policies.py +0 -0
  77. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/priority.py +0 -0
  78. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/request_contract.py +0 -0
  79. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/requirements.py +0 -0
  80. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/seal_migration.py +0 -0
  81. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/sealing.py +0 -0
  82. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/watchdog.py +0 -0
  83. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/supervisor/workspace.py +0 -0
  84. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/request_fixture.py +0 -0
  85. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_adversarial.py +0 -0
  86. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_agent_registry_chain.py +0 -0
  87. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_agents_gate.py +0 -0
  88. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_audit_check.py +0 -0
  89. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_authenticated_integrity.py +0 -0
  90. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_cli.py +0 -0
  91. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_console_scripts.py +0 -0
  92. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_core_layers.py +0 -0
  93. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_daemon.py +0 -0
  94. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_dependency_graph.py +0 -0
  95. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_doc_transcripts.py +0 -0
  96. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_docs_metadata.py +0 -0
  97. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_events.py +0 -0
  98. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_exec_trust_gate.py +0 -0
  99. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_extra_layers.py +0 -0
  100. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_failure_injection.py +0 -0
  101. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_formal_compiler.py +0 -0
  102. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_formal_planning.py +0 -0
  103. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_formal_semantics.py +0 -0
  104. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_full_contract_hardening.py +0 -0
  105. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_hmac_check_environment.py +0 -0
  106. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_hooks.py +0 -0
  107. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_integration_hardening.py +0 -0
  108. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_npm_launcher.py +0 -0
  109. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_npm_registry_visibility.py +0 -0
  110. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_readme_quickstart.py +0 -0
  111. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_registry_lock_partial.py +0 -0
  112. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_request_contract_hardening.py +0 -0
  113. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_root_cause_fixes_v220.py +0 -0
  114. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/test_runtime_root_hardening.py +0 -0
  115. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/trusted_workspace.py +0 -0
  116. {plan_auditor-2.4.4 → plan_auditor-2.4.6}/tests/wheel_cli_smoke.py +0 -0
@@ -4,6 +4,31 @@ All notable changes to plan-auditor. Versions are published to PyPI and npm.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 2.4.6 — Release alignment
8
+
9
+ - Publishes the 2.4.5 tree to PyPI and npm. The 2.4.5 commit landed after
10
+ the 2.4.5 PyPI upload, so both registries were one commit behind main.
11
+ No behavioural change beyond 2.4.5 itself.
12
+
13
+ ## 2.4.5 — Opt-in Linux privilege-separated check execution
14
+
15
+ ### Security
16
+
17
+ - Add `PLAN_AUDITOR_CHECK_ISOLATION=required` with explicit non-root
18
+ `PLAN_AUDITOR_CHECK_UID` / `PLAN_AUDITOR_CHECK_GID`: root-run Linux
19
+ supervisors execute project-controlled checks under the named UID/GID with
20
+ supplementary groups dropped, while the trusted process retains HMAC signing.
21
+ - Require root-owned protected control state, trusted workspace ancestry,
22
+ root-owned private HMAC key material and non-writable key directories.
23
+ Missing/invalid isolation configuration fails closed.
24
+ - Restrict the child environment to a small, documented allowlist, excluding
25
+ HMAC credentials, unrelated API tokens, `HOME`, `PYTHONPATH` and SSH sockets.
26
+ - Require real root-run Linux CI regression tests proving a child cannot read
27
+ either a root-owned `0600` key file or `.plan-auditor` control state.
28
+ - Document required host configuration. This is a Linux UID permission boundary,
29
+ **not** a cross-platform container/VM, network or kernel sandbox; default
30
+ same-user mode continues to have its documented limits.
31
+
7
32
  ## 2.4.4 — HMAC check subprocess boundary
8
33
 
9
34
  ### Security
@@ -21,8 +21,8 @@ authors:
21
21
  repository-code: "https://github.com/Furox-Art/plan-auditor"
22
22
  url: "https://github.com/Furox-Art/plan-auditor"
23
23
  license: MIT
24
- version: 2.4.4
25
- date-released: "2026-10-10"
24
+ version: 2.4.6
25
+ date-released: "2026-10-11"
26
26
  keywords:
27
27
  - AI coding agents
28
28
  - deterministic verification
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plan-auditor
3
- Version: 2.4.4
3
+ Version: 2.4.6
4
4
  Summary: Deterministic verification and evidence auditing for AI coding agents: sealed plans, hash-chained evidence, and an independent completion gate
5
5
  Project-URL: Homepage, https://github.com/Furox-Art/plan-auditor
6
6
  Project-URL: Repository, https://github.com/Furox-Art/plan-auditor
@@ -147,9 +147,16 @@ As of the 2.4.4 source hardening, behavioral-check subprocesses no longer inheri
147
147
  `PLAN_AUDITOR_HMAC_KEY` or `PLAN_AUDITOR_HMAC_KEY_FILE` from the verifier.
148
148
  This reduces inadvertent environment-based credential exposure, but **does not
149
149
  sandbox checks**. Project-controlled checks running as the supervisor's OS user
150
- can still read any external key file that user can read. A hostile-agent security
151
- boundary needs an independently sandboxed or lower-privilege check executor;
152
- see [deployment isolation](docs/deployment-isolation.md).
150
+ can still read any external key file that user can read.
151
+
152
+ Version 2.4.5 adds an **opt-in Linux-only privilege boundary**. A trusted root-run
153
+ supervisor can require each behavioral check to execute as a separate, non-root
154
+ UID/GID with no supplementary groups; the child cannot read a root-only HMAC key
155
+ or protected control state. Unsafe or incomplete configuration refuses execution.
156
+ This is not automatic in normal mode and is not a container, seccomp or network
157
+ sandbox. Never grant the check user access to the supervisor's key or installed
158
+ trusted program. Setup instructions and limitations:
159
+ [deployment isolation](docs/deployment-isolation.md).
153
160
 
154
161
  `plan-auditor integrity init` and `plan-auditor integrity status` manage a
155
162
  keyed authentication of evidence, seals and the agent registry. Detection is only
@@ -3,7 +3,7 @@ name: plan-auditor
3
3
  description: Invoke this skill by name as `plan-auditor` for non-trivial build/implement/fix work or whenever the user wants independent proof that an AI actually completed the requested work. It converts the task into explicit requirements and machine-verifiable steps, automatically compiles those structured requirements/dataflow into a sealed LLM-free STRIPS contract, proves every step with real command evidence, and blocks completion until every active plan has an intact contract and a fresh deterministic audit.
4
4
  argument-hint: "<task description>"
5
5
  metadata:
6
- version: "2.4.4"
6
+ version: "2.4.6"
7
7
  ---
8
8
 
9
9
  # Plan Auditor — strict plan + independent verification
@@ -40,11 +40,55 @@ must not be able to open it. Start the supervisor service with:
40
40
  PLAN_AUDITOR_HMAC_KEY_FILE=/var/lib/plan-auditor/keys/project.key
41
41
  ```
42
42
 
43
- The key file should not be inside the workspace. Use filesystem ACLs, service
44
- sandboxing or container mounts so neither `pa-agent` nor `pa-check` can read
45
- it. The stock CLI runs behavioral checks as its own subprocesses, so this
46
- three-principal topology requires an external isolated execution arrangement;
47
- it is not automatically created by `plan-auditor audit`.
43
+ The key file must be outside the workspace and be private to the trusted
44
+ supervisor. Since 2.4.5, the stock CLI offers an **opt-in Linux privilege drop**
45
+ for each behavioral check (not a container sandbox). The supervisor must run as
46
+ root; the untrusted check executes as a dedicated, unprivileged OS user.
47
+
48
+ A minimal trusted-host layout is:
49
+
50
+ ```bash
51
+ # Run once as a trusted administrator. The workspace and all ancestors must
52
+ # be root-owned. A writable workspace directory must have the sticky bit.
53
+ sudo useradd --system --no-create-home --shell /usr/sbin/nologin pa-check
54
+ sudo install -d -o root -g root -m 1777 /srv/pa-workspace
55
+ sudo install -d -o root -g root -m 0700 /srv/pa-workspace/.plan-auditor
56
+ sudo install -d -o root -g root -m 0700 /var/lib/plan-auditor/keys
57
+ # Provision a strong, random project.key through a trusted mechanism,
58
+ # owned by root, mode 0600. Never commit or print the key.
59
+ ```
60
+
61
+ The parent directories of the key file must be root-owned and not writable
62
+ by group/other. Place the trusted Plan Auditor installation and Python entry
63
+ point **outside the agent-writable workspace**, also owned by root. Configure
64
+ a host-owned service or root-run invocation:
65
+
66
+ ```bash
67
+ sudo env \
68
+ PLAN_AUDITOR_HMAC_KEY_FILE=/var/lib/plan-auditor/keys/project.key \
69
+ PLAN_AUDITOR_CHECK_ISOLATION=required \
70
+ PLAN_AUDITOR_CHECK_UID="$(id -u pa-check)" \
71
+ PLAN_AUDITOR_CHECK_GID="$(id -g pa-check)" \
72
+ /usr/local/bin/plan-auditor audit /srv/pa-workspace
73
+ ```
74
+
75
+ The path `/usr/local/bin/plan-auditor` is illustrative; it must point to a
76
+ trusted root-managed installation. The host must separately activate and seal
77
+ its authoritative plan and retain ownership of `.plan-auditor`; the agent must
78
+ not control the supervisor service's environment. The check identity must not
79
+ be root and must not have access to the HMAC file or control-state directory.
80
+
81
+ This mode rejects unsupported platforms, non-root supervisors, invalid IDs,
82
+ world/group-readable key files, insecure key parents, and writable workspace
83
+ ancestors without root ownership and sticky-bit protection. A failure stops the
84
+ check with a nonzero result; it never silently falls back to same-user execution.
85
+ Ordinary installations without these variables retain the documented default,
86
+ which **does not protect against a deliberately malicious same-user process**.
87
+
88
+ This is a Linux user-permissions boundary, **not** a namespace, seccomp, resource
89
+ quota, or network sandbox. Host operators may additionally put the untrusted
90
+ check identity in a separately restricted container/VM and must not grant it
91
+ sensitive groups, writable privileged paths, ambient capabilities or sudo rights.
48
92
 
49
93
  ## Container/VM boundary
50
94
 
@@ -137,7 +137,18 @@ separate container/VM, give the untrusted agent write access only to the intende
137
137
  workspace/product surface, and expose the HMAC key only to the supervisor identity.
138
138
  **Also isolate code execution:** product-controlled behavioral checks must execute
139
139
  under a separate, keyless OS/container identity, not merely as child processes of
140
- that privileged supervisor. The default CLI does not enforce this OS boundary.
140
+ that privileged supervisor. On Linux, an opt-in root-run mode
141
+ (`PLAN_AUDITOR_CHECK_ISOLATION=required` with distinct non-root
142
+ `PLAN_AUDITOR_CHECK_UID` and `PLAN_AUDITOR_CHECK_GID`) drops the subprocess
143
+ UID/GID and supplementary groups before running checks. The trusted supervisor
144
+ retains the HMAC key; the check cannot read its root-only key file or root-private
145
+ control state. The mode validates ownership and permissions and fails closed if
146
+ it cannot establish the boundary.
147
+
148
+ By default, or on Windows/macOS, the CLI still makes **no OS-isolation guarantee**.
149
+ UID/GID separation is not a full kernel sandbox: trusted installation paths,
150
+ host-owned configuration, capability/sudo restrictions, and optionally stronger
151
+ container/VM/network isolation remain the deployment operator's responsibility.
141
152
 
142
153
  ## Fail-closed terminology
143
154
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plan-auditor",
3
- "version": "2.4.4",
3
+ "version": "2.4.6",
4
4
  "description": "Thin Node launcher for the plan-auditor deterministic verification CLI for AI coding agents (requires Python 3.10+ on PATH)",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "plan-auditor"
7
- version = "2.4.4"
7
+ version = "2.4.6"
8
8
  description = "Deterministic verification and evidence auditing for AI coding agents: sealed plans, hash-chained evidence, and an independent completion gate"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -30,6 +30,7 @@ from contextlib import contextmanager
30
30
  from typing import TYPE_CHECKING
31
31
 
32
32
  if TYPE_CHECKING: # pragma: no cover - type checkers see the single package import
33
+ from scripts.check_isolation import check_launch_options
33
34
  from scripts.contract import PlanTrustError
34
35
  from scripts.exec_trust import require_plan_trust
35
36
  else:
@@ -37,9 +38,11 @@ else:
37
38
  # importable unprefixed. The runtime fallback is required; the TYPE_CHECKING
38
39
  # branch keeps static analysis from seeing the names bound twice.
39
40
  try:
41
+ from scripts.check_isolation import check_launch_options
40
42
  from scripts.contract import PlanTrustError
41
43
  from scripts.exec_trust import require_plan_trust
42
44
  except ImportError:
45
+ from check_isolation import check_launch_options
43
46
  from contract import PlanTrustError
44
47
  from exec_trust import require_plan_trust
45
48
 
@@ -678,25 +681,20 @@ def _kill_process_tree(proc):
678
681
 
679
682
  def _bounded_command(command, base, timeout, max_output):
680
683
  """Execute ``command`` (an argv list) with no shell and bounded resources."""
681
- # Behavioral checks execute potentially untrusted project code. Do not pass
682
- # the supervisor's authenticated-integrity credentials to these children.
683
- # This is defense in depth, not an OS boundary: another process running as
684
- # the same user may still read an externally stored key file.
685
- child_env = os.environ.copy()
686
- child_env.pop("PLAN_AUDITOR_HMAC_KEY", None)
687
- child_env.pop("PLAN_AUDITOR_HMAC_KEY_FILE", None)
688
684
  kwargs = {
689
685
  "cwd": base,
690
686
  "stdout": subprocess.PIPE,
691
687
  "stderr": subprocess.STDOUT,
692
688
  "shell": False,
693
- "env": child_env,
694
689
  }
695
690
  if os.name == "nt":
696
691
  kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
697
692
  else:
698
693
  kwargs["start_new_session"] = True
699
694
  try:
695
+ # Optional Linux root-run UID/GID drop. Even with no isolation enabled,
696
+ # never forward the HMAC key or key-file locator to behavioral checks.
697
+ kwargs.update(check_launch_options(base))
700
698
  proc = subprocess.Popen(command, **kwargs)
701
699
  except (OSError, ValueError) as exc:
702
700
  return None, "start", str(exc), False
@@ -0,0 +1,116 @@
1
+ """Opt-in Linux privilege separation for project-controlled verification checks.
2
+
3
+ This is a UID/GID security boundary, not a container, seccomp or kernel sandbox.
4
+ A trusted root-owned supervisor and a root-owned key/control plane are required.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import stat
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ MODE_ENV = "PLAN_AUDITOR_CHECK_ISOLATION"
15
+ UID_ENV = "PLAN_AUDITOR_CHECK_UID"
16
+ GID_ENV = "PLAN_AUDITOR_CHECK_GID"
17
+ KEY_ENV = "PLAN_AUDITOR_HMAC_KEY"
18
+ KEY_FILE_ENV = "PLAN_AUDITOR_HMAC_KEY_FILE"
19
+
20
+ # The isolated child must not inherit arbitrary supervisor secrets.
21
+ _SAFE_ENV = ("PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "PYTHONIOENCODING", "PYTHONUTF8")
22
+
23
+
24
+ class CheckIsolationError(ValueError):
25
+ """Refuse execution if the requested OS boundary cannot be established."""
26
+
27
+
28
+ def _positive_id(raw: str | None, name: str) -> int:
29
+ if not raw or not raw.isascii() or not raw.isdecimal():
30
+ raise CheckIsolationError(f"{name} must be a positive numeric Linux ID")
31
+ value = int(raw)
32
+ if not 0 < value < 2**31:
33
+ raise CheckIsolationError(f"{name} must be a non-root Linux ID")
34
+ return value
35
+
36
+
37
+ def _root_owned_private_control(root: Path) -> None:
38
+ workspace = root.resolve(strict=True)
39
+ # The agent must not be able to rename the workspace itself through a
40
+ # writable ancestor: private permissions on .plan-auditor alone are not enough.
41
+ for current in (workspace, *workspace.parents):
42
+ st = current.stat()
43
+ if st.st_uid != 0 or not stat.S_ISDIR(st.st_mode):
44
+ raise CheckIsolationError("workspace and ancestors must be root-owned directories")
45
+ if st.st_mode & 0o022 and not st.st_mode & stat.S_ISVTX:
46
+ raise CheckIsolationError(
47
+ "writable workspace/ancestor directories require root ownership and the sticky bit"
48
+ )
49
+ control = workspace / ".plan-auditor"
50
+ try:
51
+ control_stat = control.lstat()
52
+ except OSError as exc:
53
+ raise CheckIsolationError(f"cannot inspect trusted control directory: {exc}") from exc
54
+ if (not stat.S_ISDIR(control_stat.st_mode)
55
+ or control_stat.st_uid != 0 or control_stat.st_mode & 0o077):
56
+ raise CheckIsolationError(
57
+ "isolated checks require a root-owned, private (0700) .plan-auditor directory"
58
+ )
59
+
60
+
61
+ def _private_root_key_file(root: Path) -> None:
62
+ value = os.environ.get(KEY_FILE_ENV)
63
+ if not value:
64
+ return
65
+ supplied = Path(value).expanduser()
66
+ if supplied.is_symlink():
67
+ raise CheckIsolationError("isolated checks reject HMAC key symlinks")
68
+ try:
69
+ path = supplied.resolve(strict=True)
70
+ info = path.stat()
71
+ except OSError as exc:
72
+ raise CheckIsolationError(f"cannot inspect HMAC key: {exc}") from exc
73
+ if path.is_relative_to(root):
74
+ raise CheckIsolationError("HMAC key must be outside the workspace")
75
+ if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
76
+ raise CheckIsolationError("isolated checks require a root-owned 0600 HMAC key file")
77
+ # Neither the check user nor the agent should be able to replace the key
78
+ # by writing to one of its parent directories.
79
+ for parent in path.parents:
80
+ parent_stat = parent.stat()
81
+ if parent_stat.st_uid != 0 or parent_stat.st_mode & 0o022:
82
+ raise CheckIsolationError("HMAC key directory chain must be root-owned and non-writable")
83
+
84
+
85
+ def check_launch_options(base: str | os.PathLike[str]) -> dict[str, Any]:
86
+ """Return safe subprocess options; fail closed on partial/invalid configuration."""
87
+ mode = os.environ.get(MODE_ENV)
88
+ uid_raw = os.environ.get(UID_ENV)
89
+ gid_raw = os.environ.get(GID_ENV)
90
+ child_env = os.environ.copy()
91
+ child_env.pop(KEY_ENV, None)
92
+ child_env.pop(KEY_FILE_ENV, None)
93
+ if mode is None and uid_raw is None and gid_raw is None:
94
+ return {"env": child_env}
95
+ if mode != "required":
96
+ raise CheckIsolationError(
97
+ "check identity settings require PLAN_AUDITOR_CHECK_ISOLATION=required"
98
+ )
99
+ if not sys.platform.startswith("linux") or not hasattr(os, "geteuid") or os.geteuid() != 0:
100
+ raise CheckIsolationError("required check isolation only supports a trusted root-run Linux verifier")
101
+ uid = _positive_id(uid_raw, UID_ENV)
102
+ gid = _positive_id(gid_raw, GID_ENV)
103
+ root = Path(base).resolve(strict=True)
104
+ _root_owned_private_control(root)
105
+ _private_root_key_file(root)
106
+ # Never pass through arbitrary environment variables such as API tokens,
107
+ # HOME, GITHUB_TOKEN, SSH_AUTH_SOCK or the verifier's Python import paths.
108
+ isolated_env = {k: os.environ[k] for k in _SAFE_ENV if k in os.environ}
109
+ isolated_env["HOME"] = "/nonexistent"
110
+ isolated_env["PYTHONDONTWRITEBYTECODE"] = "1"
111
+ return {
112
+ "env": isolated_env,
113
+ "user": uid,
114
+ "group": gid,
115
+ "extra_groups": [],
116
+ }
@@ -1,5 +1,5 @@
1
1
  """Plan Auditor — AI Agent Verification Supervisor."""
2
- __version__ = "2.4.4"
2
+ __version__ = "2.4.6"
3
3
 
4
4
  from .adversarial import run_adversarial_review
5
5
  from .agents import Agent, MultiAgentRegistry
@@ -0,0 +1,150 @@
1
+ """Checks for the opt-in Linux root-supervisor / unprivileged-check boundary."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import stat
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ from scripts import audit_check as core
14
+ from scripts.check_isolation import CheckIsolationError, check_launch_options
15
+
16
+
17
+ def _request_isolation(monkeypatch, uid="65534", gid="65534"):
18
+ monkeypatch.setenv("PLAN_AUDITOR_CHECK_ISOLATION", "required")
19
+ monkeypatch.setenv("PLAN_AUDITOR_CHECK_UID", uid)
20
+ monkeypatch.setenv("PLAN_AUDITOR_CHECK_GID", gid)
21
+
22
+
23
+ def test_partial_configuration_refuses_to_run(tmp_path, monkeypatch):
24
+ monkeypatch.setenv("PLAN_AUDITOR_CHECK_UID", "65534")
25
+ rc, state, reason, _overflow = core._bounded_command(
26
+ [sys.executable, "-c", "print('must not execute')"], str(tmp_path), 5, 2048
27
+ )
28
+ assert rc is None and state == "start"
29
+ assert "require PLAN_AUDITOR_CHECK_ISOLATION=required" in reason
30
+
31
+
32
+ @pytest.mark.parametrize("uid,gid", [("0", "65534"), ("65534", "0"), ("bad", "65534"), ("", "65534")])
33
+ def test_invalid_identity_fails_closed(tmp_path, monkeypatch, uid, gid):
34
+ _request_isolation(monkeypatch, uid=uid, gid=gid)
35
+ # Simulate a privileged host to inspect the identity validator on all OSes.
36
+ monkeypatch.setattr(sys, "platform", "linux")
37
+ monkeypatch.setattr(os, "geteuid", lambda: 0, raising=False)
38
+ with pytest.raises(CheckIsolationError, match="non-root|positive"):
39
+ check_launch_options(tmp_path)
40
+
41
+
42
+ def test_required_isolation_refuses_unprivileged_verifier(tmp_path, monkeypatch):
43
+ _request_isolation(monkeypatch)
44
+ monkeypatch.setattr(os, "geteuid", lambda: 1000, raising=False)
45
+ with pytest.raises(CheckIsolationError, match="root-run Linux"):
46
+ check_launch_options(tmp_path)
47
+
48
+
49
+ @pytest.mark.skipif(not sys.platform.startswith("linux") or not hasattr(os, "geteuid") or os.geteuid() != 0,
50
+ reason="real kernel UID/GID isolation needs root-run Linux")
51
+ def test_real_unprivileged_check_cannot_read_hmac_or_control_plane(monkeypatch):
52
+ import pwd
53
+
54
+ check_user = pwd.getpwnam("nobody")
55
+ # root-owned sticky workspace allows agent-owned product files without
56
+ # permitting rename/replacement of root-owned .plan-auditor control state.
57
+ with tempfile.TemporaryDirectory(prefix="pa-os-check-", dir="/tmp") as folder, \
58
+ tempfile.TemporaryDirectory(prefix="pa-os-key-", dir="/root") as key_folder:
59
+ workspace = Path(folder)
60
+ workspace.chmod(0o1777)
61
+ control = workspace / ".plan-auditor"
62
+ control.mkdir(mode=0o700)
63
+ marker = control / "seal.json"
64
+ marker.write_text("trusted", encoding="utf-8")
65
+ key = Path(key_folder) / "secret.key"
66
+ key.write_text("not-a-real-credential", encoding="utf-8")
67
+ key.chmod(0o600)
68
+
69
+ _request_isolation(monkeypatch, str(check_user.pw_uid), str(check_user.pw_gid))
70
+ monkeypatch.setenv("PLAN_AUDITOR_HMAC_KEY_FILE", str(key))
71
+ monkeypatch.setenv("GITHUB_TOKEN", "do-not-inherit-test-token")
72
+ script = (
73
+ "import json,os,pathlib,sys;"
74
+ "key=pathlib.Path(sys.argv[1]);control=pathlib.Path(sys.argv[2]);"
75
+ "definitely_no_key=os.getenv('PLAN_AUDITOR_HMAC_KEY_FILE') is None;"
76
+ "denied=[];"
77
+ "\nfor p in (key,control):"
78
+ "\n try: p.read_bytes();denied.append(False)"
79
+ "\n except PermissionError: denied.append(True)"
80
+ "\nprint(json.dumps({'uid':os.geteuid(),'gid':os.getegid(),"
81
+ "'denied':denied,'key_hidden':definitely_no_key,"
82
+ "'other_secret_hidden':os.getenv('GITHUB_TOKEN') is None}))"
83
+ )
84
+ passed, detail, output = core.run_check(
85
+ {"type": "run", "argv": [sys.executable, "-c", script, str(key), str(marker)],
86
+ "expect_exit": 0, "output_regex": "key_hidden"},
87
+ str(workspace),
88
+ )
89
+ assert passed, (detail, output)
90
+ rc, state, raw, overflow = core._bounded_command(
91
+ [sys.executable, "-c", script, str(key), str(marker)], str(workspace), 15, 8192
92
+ )
93
+ assert (rc, state, overflow) == (0, "ok", False), raw
94
+ result = json.loads(raw.strip())
95
+ assert result == {
96
+ "uid": check_user.pw_uid, "gid": check_user.pw_gid,
97
+ "denied": [True, True], "key_hidden": True, "other_secret_hidden": True,
98
+ }
99
+ assert marker.read_text(encoding="utf-8") == "trusted"
100
+ assert stat.S_IMODE(key.stat().st_mode) == 0o600
101
+
102
+
103
+ @pytest.mark.skipif(not sys.platform.startswith("linux") or not hasattr(os, "geteuid") or os.geteuid() != 0,
104
+ reason="real kernel filesystem policy needs root-run Linux")
105
+ def test_insecure_external_key_file_is_rejected(monkeypatch):
106
+ import pwd
107
+
108
+ user = pwd.getpwnam("nobody")
109
+ with tempfile.TemporaryDirectory(prefix="pa-os-check-", dir="/tmp") as folder, \
110
+ tempfile.TemporaryDirectory(prefix="pa-os-key-", dir="/root") as key_folder:
111
+ root = Path(folder)
112
+ root.chmod(0o1777)
113
+ (root / ".plan-auditor").mkdir(mode=0o700)
114
+ key = Path(key_folder) / "secret.key"
115
+ key.write_text("test-only-key", encoding="utf-8")
116
+ key.chmod(0o644)
117
+ _request_isolation(monkeypatch, str(user.pw_uid), str(user.pw_gid))
118
+ monkeypatch.setenv("PLAN_AUDITOR_HMAC_KEY_FILE", str(key))
119
+ with pytest.raises(CheckIsolationError, match="0600"):
120
+ check_launch_options(root)
121
+
122
+
123
+ @pytest.mark.skipif(not sys.platform.startswith("linux") or not hasattr(os, "geteuid") or os.geteuid() != 0,
124
+ reason="real Linux ownership checks need root")
125
+ def test_insecure_workspace_can_not_replace_trusted_control_state(monkeypatch):
126
+ import pwd
127
+
128
+ check_user = pwd.getpwnam("nobody")
129
+ with tempfile.TemporaryDirectory(prefix="pa-os-check-", dir="/tmp") as folder:
130
+ workspace = Path(folder)
131
+ workspace.chmod(0o777) # world writable without sticky bit
132
+ (workspace / ".plan-auditor").mkdir(mode=0o700)
133
+ _request_isolation(monkeypatch, str(check_user.pw_uid), str(check_user.pw_gid))
134
+ with pytest.raises(CheckIsolationError, match="sticky bit"):
135
+ check_launch_options(workspace)
136
+
137
+
138
+ @pytest.mark.skipif(not sys.platform.startswith("linux") or not hasattr(os, "geteuid") or os.geteuid() != 0,
139
+ reason="real Linux ownership checks need root")
140
+ def test_insecure_control_directory_is_rejected(monkeypatch):
141
+ import pwd
142
+
143
+ check_user = pwd.getpwnam("nobody")
144
+ with tempfile.TemporaryDirectory(prefix="pa-os-check-", dir="/tmp") as folder:
145
+ workspace = Path(folder)
146
+ workspace.chmod(0o1777)
147
+ (workspace / ".plan-auditor").mkdir(mode=0o755)
148
+ _request_isolation(monkeypatch, str(check_user.pw_uid), str(check_user.pw_gid))
149
+ with pytest.raises(CheckIsolationError, match="private"):
150
+ check_launch_options(workspace)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes