pawnlogic-security 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """Scope-gated security tooling distributed independently of PawnLogic core."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,406 @@
1
+ """Optional, fail-closed child-process adapter using a single JSON exchange."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import signal
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ from collections.abc import Mapping
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import BinaryIO, Protocol
18
+
19
+ from core.operation_policy import (
20
+ OperationAction,
21
+ OperationDecision,
22
+ classify_shell_command,
23
+ redact_command,
24
+ )
25
+
26
+ _SENSITIVE_FIELD_RE = re.compile(
27
+ r"(?:api[_-]?key|access[_-]?key|token|password|passwd|secret|private[_-]?key)",
28
+ re.IGNORECASE,
29
+ )
30
+
31
+
32
+ class OperationPolicyEvaluator(Protocol):
33
+ """Callable shape provided by the host Operation Policy."""
34
+
35
+ def __call__(
36
+ self,
37
+ command: str,
38
+ *,
39
+ cwd: str | Path,
40
+ workspace_dir: str | Path | None = None,
41
+ ) -> OperationDecision: ...
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ChildAdapterConfig:
46
+ """Caller-owned process configuration; disabled unless explicitly enabled."""
47
+
48
+ argv: tuple[str, ...] = field(default=(), repr=False)
49
+ enabled: bool = False
50
+ timeout_seconds: float = 10.0
51
+ max_input_bytes: int = 64 * 1024
52
+ max_output_bytes: int = 1024 * 1024
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class ChildAdapterResult:
57
+ """Deterministic result returned for every adapter outcome."""
58
+
59
+ ok: bool
60
+ code: str
61
+ message: str
62
+ data: object | None = None
63
+
64
+ def to_dict(self) -> dict[str, object]:
65
+ return {
66
+ "ok": self.ok,
67
+ "code": self.code,
68
+ "message": self.message,
69
+ "data": self.data,
70
+ }
71
+
72
+
73
+ class ChildProcessAdapter:
74
+ """Run one configured child process for one bounded JSON request."""
75
+
76
+ def __init__(
77
+ self,
78
+ config: ChildAdapterConfig,
79
+ *,
80
+ policy_evaluator: OperationPolicyEvaluator = classify_shell_command,
81
+ ) -> None:
82
+ self._config = config
83
+ self._policy_evaluator = policy_evaluator
84
+
85
+ def run(self, request: Mapping[str, object]) -> ChildAdapterResult:
86
+ if not self._config.enabled:
87
+ return ChildAdapterResult(
88
+ ok=False,
89
+ code="disabled",
90
+ message="Child-process adapter is disabled.",
91
+ )
92
+
93
+ cwd = Path.cwd()
94
+ decision = self._policy_evaluator(
95
+ shlex.join(self._config.argv),
96
+ cwd=cwd,
97
+ workspace_dir=cwd,
98
+ )
99
+ if decision.action is not OperationAction.ALLOW:
100
+ code = (
101
+ "policy_denied"
102
+ if decision.action is OperationAction.DENY
103
+ else "policy_confirmation_required"
104
+ )
105
+ return ChildAdapterResult(
106
+ ok=False,
107
+ code=code,
108
+ message="Host Operation Policy refused child-process execution.",
109
+ )
110
+
111
+ executable = shutil.which(self._config.argv[0]) if self._config.argv else None
112
+ if executable is None:
113
+ return ChildAdapterResult(
114
+ ok=False,
115
+ code="executable_not_found",
116
+ message="Configured executable was not found.",
117
+ )
118
+
119
+ if (
120
+ self._config.timeout_seconds <= 0
121
+ or self._config.max_input_bytes <= 0
122
+ or self._config.max_output_bytes <= 0
123
+ ):
124
+ return ChildAdapterResult(
125
+ ok=False,
126
+ code="invalid_configuration",
127
+ message="Adapter limits must be positive.",
128
+ )
129
+
130
+ try:
131
+ input_bytes = (
132
+ json.dumps(
133
+ dict(request),
134
+ ensure_ascii=False,
135
+ separators=(",", ":"),
136
+ sort_keys=True,
137
+ allow_nan=False,
138
+ )
139
+ + "\n"
140
+ ).encode("utf-8")
141
+ except (TypeError, ValueError):
142
+ return ChildAdapterResult(
143
+ ok=False,
144
+ code="invalid_request",
145
+ message="Request is not valid JSON.",
146
+ )
147
+ if len(input_bytes) > self._config.max_input_bytes:
148
+ return ChildAdapterResult(
149
+ ok=False,
150
+ code="input_too_large",
151
+ message="JSON request exceeds the configured input limit.",
152
+ )
153
+
154
+ argv = (executable, *self._config.argv[1:])
155
+ try:
156
+ process = subprocess.Popen(
157
+ argv,
158
+ stdin=subprocess.PIPE,
159
+ stdout=subprocess.PIPE,
160
+ stderr=subprocess.PIPE,
161
+ shell=False,
162
+ env=_scrubbed_environment(),
163
+ start_new_session=os.name == "posix",
164
+ )
165
+ except OSError:
166
+ return ChildAdapterResult(
167
+ ok=False,
168
+ code="process_start_failed",
169
+ message="Configured child process could not be started.",
170
+ )
171
+
172
+ exchange_code, stdout = _exchange_json(
173
+ process,
174
+ input_bytes=input_bytes,
175
+ timeout_seconds=self._config.timeout_seconds,
176
+ max_output_bytes=self._config.max_output_bytes,
177
+ )
178
+ if exchange_code == "timeout":
179
+ return ChildAdapterResult(
180
+ ok=False,
181
+ code="timeout",
182
+ message="Child process exceeded its configured timeout.",
183
+ )
184
+ if exchange_code == "output_too_large":
185
+ return ChildAdapterResult(
186
+ ok=False,
187
+ code="output_too_large",
188
+ message="Child-process output exceeds the configured limit.",
189
+ )
190
+ if process.returncode != 0:
191
+ return ChildAdapterResult(
192
+ ok=False,
193
+ code="process_failed",
194
+ message="Child process exited unsuccessfully.",
195
+ )
196
+
197
+ try:
198
+ data = json.loads(
199
+ stdout.decode("utf-8"),
200
+ parse_constant=_reject_json_constant,
201
+ )
202
+ except (UnicodeDecodeError, ValueError):
203
+ return ChildAdapterResult(
204
+ ok=False,
205
+ code="malformed_output",
206
+ message="Child process returned malformed JSON.",
207
+ )
208
+ return ChildAdapterResult(
209
+ ok=True,
210
+ code="ok",
211
+ message="Child process completed successfully.",
212
+ data=_redact_json(data),
213
+ )
214
+
215
+
216
+ def _scrubbed_environment() -> dict[str, str]:
217
+ """Return a minimal environment without inheriting caller secrets."""
218
+ environment = {
219
+ "PATH": os.defpath,
220
+ "PYTHONIOENCODING": "utf-8",
221
+ }
222
+ for name in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"):
223
+ value = os.environ.get(name)
224
+ if value:
225
+ environment[name] = value
226
+ return environment
227
+
228
+
229
+ def _reject_json_constant(value: str) -> None:
230
+ raise ValueError(f"Non-standard JSON constant: {value}")
231
+
232
+
233
+ def _redact_json(value: object) -> object:
234
+ if isinstance(value, dict):
235
+ redacted: dict[str, object] = {}
236
+ for key in sorted(value):
237
+ item = value[key]
238
+ redacted[key] = (
239
+ "<redacted>"
240
+ if _SENSITIVE_FIELD_RE.fullmatch(key)
241
+ else _redact_json(item)
242
+ )
243
+ return redacted
244
+ if isinstance(value, list):
245
+ return [_redact_json(item) for item in value]
246
+ if isinstance(value, str):
247
+ return redact_command(value)
248
+ return value
249
+
250
+
251
+ class _BoundedOutput:
252
+ """Share one byte budget across stdout and stderr reader threads."""
253
+
254
+ def __init__(self, limit: int) -> None:
255
+ self._limit = limit
256
+ self._total = 0
257
+ self._stdout = bytearray()
258
+ self._lock = threading.Lock()
259
+ self.overflow = threading.Event()
260
+
261
+ def add(self, chunk: bytes, *, capture: bool) -> None:
262
+ with self._lock:
263
+ remaining = self._limit - self._total
264
+ if len(chunk) > remaining:
265
+ if capture and remaining > 0:
266
+ self._stdout.extend(chunk[:remaining])
267
+ self._total = self._limit
268
+ self.overflow.set()
269
+ return
270
+ self._total += len(chunk)
271
+ if capture:
272
+ self._stdout.extend(chunk)
273
+
274
+ def stdout(self) -> bytes:
275
+ with self._lock:
276
+ return bytes(self._stdout)
277
+
278
+
279
+ def _exchange_json(
280
+ process: subprocess.Popen[bytes],
281
+ *,
282
+ input_bytes: bytes,
283
+ timeout_seconds: float,
284
+ max_output_bytes: int,
285
+ ) -> tuple[str, bytes]:
286
+ assert process.stdin is not None
287
+ assert process.stdout is not None
288
+ assert process.stderr is not None
289
+
290
+ output = _BoundedOutput(max_output_bytes)
291
+ stdin_done = threading.Event()
292
+ stdout_done = threading.Event()
293
+ stderr_done = threading.Event()
294
+ threads = (
295
+ threading.Thread(
296
+ target=_write_input,
297
+ args=(process.stdin, input_bytes, stdin_done),
298
+ daemon=True,
299
+ ),
300
+ threading.Thread(
301
+ target=_read_output,
302
+ args=(process.stdout, output, True, stdout_done),
303
+ daemon=True,
304
+ ),
305
+ threading.Thread(
306
+ target=_read_output,
307
+ args=(process.stderr, output, False, stderr_done),
308
+ daemon=True,
309
+ ),
310
+ )
311
+ for thread in threads:
312
+ thread.start()
313
+
314
+ deadline = time.monotonic() + timeout_seconds
315
+ exchange_code = "ok"
316
+ while True:
317
+ if output.overflow.is_set():
318
+ exchange_code = "output_too_large"
319
+ break
320
+ process_finished = process.poll() is not None
321
+ streams_finished = stdin_done.is_set() and stdout_done.is_set() and stderr_done.is_set()
322
+ if process_finished and streams_finished:
323
+ break
324
+ remaining = deadline - time.monotonic()
325
+ if remaining <= 0:
326
+ exchange_code = "timeout"
327
+ break
328
+ output.overflow.wait(timeout=min(0.01, remaining))
329
+
330
+ if exchange_code != "ok":
331
+ _terminate_process_group(process)
332
+ else:
333
+ process.wait()
334
+
335
+ for thread in threads:
336
+ thread.join(timeout=0.2)
337
+ return exchange_code, output.stdout()
338
+
339
+
340
+ def _write_input(stream: BinaryIO, data: bytes, done: threading.Event) -> None:
341
+ try:
342
+ stream.write(data)
343
+ stream.flush()
344
+ except (BrokenPipeError, OSError):
345
+ pass
346
+ finally:
347
+ try:
348
+ stream.close()
349
+ finally:
350
+ done.set()
351
+
352
+
353
+ def _read_output(
354
+ stream: BinaryIO,
355
+ output: _BoundedOutput,
356
+ capture: bool,
357
+ done: threading.Event,
358
+ ) -> None:
359
+ try:
360
+ read_chunk = getattr(stream, "read1", stream.read)
361
+ while not output.overflow.is_set():
362
+ chunk = read_chunk(8192)
363
+ if not chunk:
364
+ break
365
+ output.add(chunk, capture=capture)
366
+ except OSError:
367
+ pass
368
+ finally:
369
+ try:
370
+ stream.close()
371
+ finally:
372
+ done.set()
373
+
374
+
375
+ def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
376
+ """Terminate the configured process and its process group when supported."""
377
+ if os.name == "posix":
378
+ try:
379
+ os.killpg(process.pid, signal.SIGTERM)
380
+ except ProcessLookupError:
381
+ pass
382
+ try:
383
+ process.wait(timeout=0.2)
384
+ except subprocess.TimeoutExpired:
385
+ pass
386
+ try:
387
+ os.killpg(process.pid, signal.SIGKILL)
388
+ except ProcessLookupError:
389
+ pass
390
+ if process.poll() is None:
391
+ process.wait()
392
+ return
393
+
394
+ process.terminate()
395
+ try:
396
+ process.wait(timeout=0.2)
397
+ except subprocess.TimeoutExpired:
398
+ process.kill()
399
+ process.wait()
400
+
401
+
402
+ __all__ = [
403
+ "ChildAdapterConfig",
404
+ "ChildAdapterResult",
405
+ "ChildProcessAdapter",
406
+ ]
@@ -0,0 +1,182 @@
1
+ """The ``pawn-security`` command.
2
+
3
+ This is intentionally thin. It validates scope files and delegates workflow
4
+ execution to the same scope-gated runner used by the PawnLogic Extension. It
5
+ cannot enable the Extension inside a host.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import asyncio
12
+ import os
13
+ import sys
14
+ from collections.abc import Sequence
15
+ from pathlib import Path
16
+
17
+ from pawnlogic_security import __version__
18
+ from pawnlogic_security.evidence import EvidenceLog
19
+ from pawnlogic_security.extension import EXTENSION_NAME, MANIFEST
20
+ from pawnlogic_security.scope import from_scope_file
21
+ from pawnlogic_security.scope_file import load as load_scope_file
22
+ from pawnlogic_security.scope_manager import ScopeManager
23
+ from pawnlogic_security.workflows import WorkflowRunner, WorkflowRunStore
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="pawn-security",
29
+ description=(
30
+ "Inspect the PawnLogic security Extension. Enable it from inside "
31
+ "PawnLogic with: /extension enable " + EXTENSION_NAME
32
+ ),
33
+ )
34
+ parser.add_argument(
35
+ "--version",
36
+ action="version",
37
+ version=f"pawnlogic-security {__version__}",
38
+ )
39
+ parser.add_argument(
40
+ "--manifest",
41
+ action="store_true",
42
+ help="Print the Extension manifest the host will validate.",
43
+ )
44
+
45
+ subparsers = parser.add_subparsers(dest="command")
46
+
47
+ scope_parser = subparsers.add_parser(
48
+ "scope",
49
+ help="Manage Engagement Scope files.",
50
+ )
51
+ scope_sub = scope_parser.add_subparsers(dest="scope_command")
52
+
53
+ validate_parser = scope_sub.add_parser(
54
+ "validate",
55
+ help="Validate a scope file and report any issues.",
56
+ )
57
+ validate_parser.add_argument(
58
+ "file",
59
+ help="Path to the scope JSON file.",
60
+ )
61
+
62
+ run_parser = subparsers.add_parser(
63
+ "run",
64
+ help="Run a built-in workflow through the shared scope-gated runner.",
65
+ )
66
+ run_parser.add_argument("workflow", help="Built-in workflow name.")
67
+ run_parser.add_argument(
68
+ "--scope",
69
+ required=True,
70
+ help="Path to the Engagement Scope JSON file.",
71
+ )
72
+
73
+ return parser
74
+
75
+
76
+ def _cmd_scope_validate(file: str) -> int:
77
+ """Validate a scope file and print a summary or the errors."""
78
+ path = Path(file).expanduser()
79
+ if not path.is_file():
80
+ print(f"error: file not found: {path}", file=sys.stderr)
81
+ return 1
82
+ try:
83
+ scope_file = load_scope_file(path)
84
+ except ValueError as error:
85
+ print(f"error: {error}", file=sys.stderr)
86
+ return 1
87
+
88
+ # Also try building a full EngagementScope to catch validation errors
89
+ # in the conversion step (CIDR normalization, etc.).
90
+ try:
91
+ scope = from_scope_file(scope_file, clock=0.0)
92
+ except (ValueError, TypeError) as error:
93
+ print(f"error: scope construction failed: {error}", file=sys.stderr)
94
+ return 1
95
+
96
+ print(f"identifier: {scope.identifier}")
97
+ print(f"authorized: {scope.authorized_by}")
98
+ print(f"expires at: {scope.expires_at}")
99
+ print(f"targets: {len(scope.targets)}")
100
+ if scope.exclude:
101
+ print(f"exclude: {len(scope.exclude)} entries")
102
+ if scope.allow_active:
103
+ print("active: yes")
104
+ if scope.max_requests:
105
+ print(f"max_req: {scope.max_requests}")
106
+ if scope.max_concurrency:
107
+ print(f"max_concur: {scope.max_concurrency}")
108
+ if scope.max_duration:
109
+ print(f"max_dur: {scope.max_duration}s")
110
+ if scope.evidence_dir:
111
+ print(f"evidence: {scope.evidence_dir}")
112
+ if scope.ports:
113
+ print(f"ports: {len(scope.ports)} entries")
114
+ if scope_file.actions:
115
+ print(f"actions: {', '.join(sorted(scope_file.actions))}")
116
+ print("scope file is valid")
117
+ return 0
118
+
119
+
120
+ def _cmd_run(workflow: str, scope_file: str) -> int:
121
+ manager = ScopeManager()
122
+ security_home = _runtime_home() / "security"
123
+ try:
124
+ manager.set_scope(Path(scope_file).expanduser())
125
+ evidence_path = manager.evidence_path(
126
+ security_home / "evidence.jsonl"
127
+ )
128
+ runner = WorkflowRunner(
129
+ evidence=EvidenceLog(path=evidence_path),
130
+ run_store=WorkflowRunStore(evidence_path.parent / "runs"),
131
+ )
132
+ result = asyncio.run(runner.run(workflow, manager))
133
+ except (OSError, ValueError) as error:
134
+ print(f"error: {error}", file=sys.stderr)
135
+ return 1
136
+ finally:
137
+ manager.close()
138
+ print(result.render())
139
+ return 0
140
+
141
+
142
+ def _runtime_home() -> Path:
143
+ configured = os.environ.get("PAWNLOGIC_HOME")
144
+ if configured:
145
+ return Path(configured).expanduser()
146
+ return Path.home() / ".pawnlogic"
147
+
148
+
149
+ def main(argv: Sequence[str] | None = None) -> int:
150
+ args = build_parser().parse_args(argv)
151
+
152
+ if args.command == "scope":
153
+ if args.scope_command == "validate":
154
+ return _cmd_scope_validate(args.file)
155
+ # `pawn-security scope` with no subcommand shows scope help.
156
+ build_parser().parse_args([*argv, "--help"] if argv else ["scope", "--help"])
157
+ return 0 # pragma: no cover
158
+
159
+ if args.command == "run":
160
+ return _cmd_run(args.workflow, args.scope)
161
+
162
+ if args.manifest:
163
+ print(f"name: {MANIFEST.name}")
164
+ print(f"version: {MANIFEST.version}")
165
+ print(f"core_version_spec: {MANIFEST.core_version_spec}")
166
+ print(f"api_version: {MANIFEST.api_version}")
167
+ print(f"capabilities: {', '.join(sorted(MANIFEST.capabilities))}")
168
+ return 0
169
+
170
+ print(f"pawnlogic-security {__version__}")
171
+ print(f"Extension name: {EXTENSION_NAME}")
172
+ print("This Extension stays disabled until it is explicitly enabled.")
173
+ print(f"Enable it inside PawnLogic with: /extension enable {EXTENSION_NAME}")
174
+ print(
175
+ "Active operations additionally require an Engagement Scope that names "
176
+ "the target and permits active work."
177
+ )
178
+ return 0
179
+
180
+
181
+ if __name__ == "__main__": # pragma: no cover - module entry point
182
+ raise SystemExit(main())