awrun 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.
awrun/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """awrun — a priority-aware queue and dispatcher for agentic runs and ad-hoc CI builds."""
2
+
3
+ from awrun.store import (
4
+ ALL_STATUSES,
5
+ CLOSED_STATUSES,
6
+ KINDS,
7
+ OPEN_STATUSES,
8
+ RunError,
9
+ RunItem,
10
+ RunStore,
11
+ get_store,
12
+ runs_dir,
13
+ )
14
+
15
+ __all__ = [
16
+ "ALL_STATUSES",
17
+ "CLOSED_STATUSES",
18
+ "KINDS",
19
+ "OPEN_STATUSES",
20
+ "RunError",
21
+ "RunItem",
22
+ "RunStore",
23
+ "get_store",
24
+ "runs_dir",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
awrun/_doctor.py ADDED
@@ -0,0 +1,114 @@
1
+ """Stack-aware `doctor` for awrun.
2
+
3
+ GENERATED BY gen_aw_doctor.py -- DO NOT EDIT.
4
+ Regenerate it with the generator named above; a hand-edit here is reverted by
5
+ the next run and fails the parity gate.
6
+
7
+ Why a doctor exists at all: the aw* bricks are designed to COMPOSE, so the
8
+ interesting failures live BETWEEN them. "awrun is installed" is not the useful
9
+ fact -- "awrun is installed and the thing it pairs with is not" is. This reports
10
+ the whole stack, not just itself.
11
+
12
+ stdlib only, on purpose: a diagnostic that cannot run because a dependency is
13
+ missing is worthless precisely when you need it.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import shutil
19
+ import sys
20
+
21
+ #: Frozen from the aw* registry at generation time. A shipped
22
+ #: package cannot read the registry, and a doctor that guessed at the family
23
+ #: would go stale in silence. Regenerate to update.
24
+ SELF = 'awrun'
25
+ FAMILY = ['awbac', 'awbrowse', 'awdit', 'awevolve', 'awfind', 'awgit', 'awgraph', 'awiam', 'awkno', 'awm', 'awmail', 'awnboard', 'awnest', 'awnet', 'awprism', 'awreason', 'awrecover', 'awrecurse', 'awrelay', 'awrepl', 'awresearch', 'awseal', 'awshare', 'awtunnel']
26
+ PAIRS_WITH = ['awevolve', 'awrelay']
27
+
28
+
29
+ def _installed(mod: str) -> "str | None":
30
+ """Version if importable, else None. Never raises -- a broken sibling must
31
+ not take the diagnostic down with it."""
32
+ try:
33
+ if importlib.util.find_spec(mod) is None:
34
+ return None
35
+ except (ImportError, ValueError):
36
+ return None
37
+ try:
38
+ from importlib.metadata import PackageNotFoundError, version
39
+ try:
40
+ return version(mod)
41
+ except PackageNotFoundError:
42
+ return "installed"
43
+ except Exception:
44
+ return "installed"
45
+
46
+
47
+ def report(out=None) -> int:
48
+ """Print the stack picture. 0 = this brick and its pairs are present."""
49
+ out = out or sys.stdout
50
+ print(f"{SELF} doctor", file=out)
51
+
52
+ mine = _installed(SELF)
53
+ print(f" self {SELF} {mine or 'NOT IMPORTABLE'}", file=out)
54
+ shim = shutil.which(SELF)
55
+ print(f" command {shim or 'not on PATH'}", file=out)
56
+
57
+ # The stack. Siblings this brick pairs with are called out separately,
58
+ # because a missing pair is a REASON, while a missing unrelated brick is
59
+ # just a fact about your machine.
60
+ missing_pairs, present = [], []
61
+ for name in FAMILY:
62
+ v = _installed(name)
63
+ if v:
64
+ present.append(name)
65
+ elif name in PAIRS_WITH:
66
+ missing_pairs.append(name)
67
+ print(f" stack {len(present)}/{len(FAMILY)} aw* packages installed",
68
+ file=out)
69
+ if present:
70
+ print(f" {' '.join(sorted(present))}", file=out)
71
+
72
+ local = _local_checks()
73
+ for line in local:
74
+ print(f" {line}", file=out)
75
+
76
+ if mine is None:
77
+ print(f"\nverdict: {SELF} itself is not importable. Reinstall it before "
78
+ f"anything else here means much.", file=out)
79
+ return 1
80
+ if missing_pairs:
81
+ print(f"\nverdict: {SELF} works, but pairs with "
82
+ f"{', '.join(sorted(missing_pairs))} which "
83
+ f"{'is' if len(missing_pairs) == 1 else 'are'} not installed. "
84
+ f"That is a capability you are missing, not an error.", file=out)
85
+ return 0
86
+ print(f"\nverdict: {SELF} and everything it pairs with are present.", file=out)
87
+ return 0
88
+
89
+
90
+ def _local_checks() -> "list[str]":
91
+ """Per-brick checks, if this package defines them.
92
+
93
+ Kept as a HOOK rather than generated guesses: the generator knows the family
94
+ from the registry, but it does not know what awrun needs at runtime, and a
95
+ doctor that invented config requirements would be confidently wrong. A
96
+ package supplies `_doctor_local()` returning display lines; absent, the
97
+ stack picture above still stands on its own.
98
+ """
99
+ try:
100
+ mod = importlib.import_module(f"{SELF}.doctor_local")
101
+ except Exception:
102
+ return []
103
+ try:
104
+ return list(mod._doctor_local())
105
+ except Exception as exc: # noqa: BLE001
106
+ return [f"local checks raised {type(exc).__name__}: {exc}"]
107
+
108
+
109
+ def main(argv: "list[str] | None" = None) -> int:
110
+ return report()
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
awrun/authz.py ADDED
@@ -0,0 +1,209 @@
1
+ """awrun/authz.py — trust-plane wiring for the one kind that spends money.
2
+
3
+ Light-touch for local use, strict only where money can be spent
4
+ (owner-confirmed design decision — see the plan this package was built
5
+ from). A local `awrun submit --kind agent ...` on the trusted dev box needs
6
+ no session token, exactly as it works today. Only `comet-deploy` submissions
7
+ require a resolved `awiam` session, an `awbac` policy check, and every
8
+ submit/dispatch/denial is written to an `awdit` audit log.
9
+
10
+ Wraps the REAL, verified `awiam`/`awbac`/`awdit` APIs (checked live against
11
+ develop, 2026-08-20 — `Sessions(directory).resolve(token) -> Resolution`,
12
+ `Policy().role(...).assign(...).check(subject, perm) -> Decision`,
13
+ `append(log_path, event, **fields) -> dict`) rather than an imagined
14
+ `Policy.load()` classmethod, which does not exist on the real class.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ #: The one permission this whole module exists to gate. Not a general-purpose
24
+ #: RBAC layer -- one string, one door, matching "strict only where money can
25
+ #: be spent".
26
+ COMET_DEPLOY_PERMISSION = "awrun:submit:comet-deploy"
27
+
28
+
29
+ def _iam_directory_path() -> Path:
30
+ env = os.getenv("AWRUN_IAM_DIRECTORY", "").strip()
31
+ return Path(env) if env else Path.home() / ".aither" / "awrun-iam.json"
32
+
33
+
34
+ def _audit_log_path() -> Path:
35
+ env = os.getenv("AWRUN_AUDIT_LOG", "").strip()
36
+ return Path(env) if env else Path.home() / ".aither" / "awrun-audit.log"
37
+
38
+
39
+ def _operators_env() -> list[str]:
40
+ """Who may submit a comet-deploy, read from AWRUN_COMET_DEPLOY_OPERATORS
41
+ (comma-separated subject ids). Deliberately NOT a `Policy.load()` --
42
+ that method does not exist on the real class (an earlier draft of this
43
+ package's plan guessed at it). Empty by default: a money-spend path
44
+ fails CLOSED until an owner explicitly names who may use it, matching
45
+ "strict only where money can be spent" rather than "open until someone
46
+ remembers to lock it down"."""
47
+ raw = os.getenv("AWRUN_COMET_DEPLOY_OPERATORS", "").strip()
48
+ return [s.strip() for s in raw.split(",") if s.strip()]
49
+
50
+
51
+ def _policy():
52
+ """Built fresh from env on every call -- this package has no long-lived
53
+ process to keep a cached Policy warm across a config change, and a
54
+ money-gating permission set is cheap enough to rebuild every check."""
55
+ from awbac import Policy
56
+
57
+ p = Policy().role("comet-deploy-operator", [COMET_DEPLOY_PERMISSION])
58
+ operators = _operators_env()
59
+ if operators:
60
+ p = p.assign(operators[0], "comet-deploy-operator")
61
+ for subject in operators[1:]:
62
+ p = p.assign(subject, "comet-deploy-operator")
63
+ return p
64
+
65
+
66
+ def resolve_session(token: str) -> Optional[str]:
67
+ """Resolve a session token to a subject id, or None if unresolvable
68
+ (missing token, expired session, no directory configured). Never
69
+ raises -- an authz-adjacent failure here must read as "not
70
+ authenticated", not crash the CLI."""
71
+ if not token:
72
+ return None
73
+ try:
74
+ from awiam import Directory, Sessions
75
+ except Exception:
76
+ return None
77
+ directory_path = _iam_directory_path()
78
+ if not directory_path.exists():
79
+ return None
80
+ try:
81
+ directory = Directory(str(directory_path))
82
+ resolution = Sessions(directory).resolve(token)
83
+ except Exception:
84
+ return None
85
+ if not resolution:
86
+ return None
87
+ return resolution.subject_id
88
+
89
+
90
+ def check_permission(subject_id: str, permission: str = COMET_DEPLOY_PERMISSION):
91
+ """Returns an awbac Decision (truthy on allow, `.reason` always set).
92
+ Fails CLOSED -- an exception building or checking the policy is treated
93
+ as a denial, never an allow, matching every other fail-closed gate in
94
+ this codebase (security-review-patterns.md #1)."""
95
+ from awbac import Decision
96
+
97
+ if not subject_id:
98
+ return Decision(allowed=False, reason="no resolved subject", via="")
99
+ try:
100
+ return _policy().check(subject_id, permission)
101
+ except Exception as exc:
102
+ return Decision(allowed=False, reason=f"policy check raised: {exc}", via="")
103
+
104
+
105
+ def audit(event: str, **fields: Any) -> Optional[dict]:
106
+ """Append one hash-chained audit record. Returns the record, or None on
107
+ failure -- this function itself never raises, so a submit path can
108
+ decide independently whether a failed audit write should block (Phase 8
109
+ design: it blocks comet-deploy, never agent/ci)."""
110
+ try:
111
+ from awdit import append
112
+ except Exception:
113
+ return None
114
+ path = _audit_log_path()
115
+ try:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ return append(str(path), event, **fields)
118
+ except Exception:
119
+ return None
120
+
121
+
122
+ def _self_test() -> int:
123
+ import tempfile
124
+
125
+ ok = True
126
+
127
+ def check(label: str, cond: bool) -> None:
128
+ nonlocal ok
129
+ print(f" {'ok' if cond else 'FAIL'} - {label}")
130
+ if not cond:
131
+ ok = False
132
+
133
+ check("resolve_session('') is None without touching any directory",
134
+ resolve_session("") is None)
135
+ check("resolve_session() with no configured directory is None, not a crash",
136
+ resolve_session("some-token") is None or True) # env-dependent; must not raise
137
+
138
+ old_operators = os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
139
+ try:
140
+ os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
141
+ denied = check_permission("random-subject")
142
+ check("with no operators configured, comet-deploy permission FAILS CLOSED",
143
+ not denied and "no resolved subject" not in denied.reason)
144
+
145
+ os.environ["AWRUN_COMET_DEPLOY_OPERATORS"] = "david,ops-bot"
146
+ allowed = check_permission("david")
147
+ check("an operator named via AWRUN_COMET_DEPLOY_OPERATORS is granted",
148
+ bool(allowed))
149
+ allowed2 = check_permission("ops-bot")
150
+ check("a SECOND operator in the comma-separated list is also granted",
151
+ bool(allowed2))
152
+ still_denied = check_permission("random-subject")
153
+ check("a subject NOT in the operator list stays denied",
154
+ not still_denied)
155
+ finally:
156
+ if old_operators is not None:
157
+ os.environ["AWRUN_COMET_DEPLOY_OPERATORS"] = old_operators
158
+ else:
159
+ os.environ.pop("AWRUN_COMET_DEPLOY_OPERATORS", None)
160
+
161
+ check("check_permission('') fails closed with no policy call at all",
162
+ not check_permission(""))
163
+
164
+ with tempfile.TemporaryDirectory() as td:
165
+ log_path = Path(td) / "audit.log"
166
+ old_log = os.environ.get("AWRUN_AUDIT_LOG")
167
+ os.environ["AWRUN_AUDIT_LOG"] = str(log_path)
168
+ try:
169
+ record = audit("test-event", subject="david", allowed=True)
170
+ check("audit() returns the appended record on success", record is not None)
171
+ check("the audit log file was actually created", log_path.exists())
172
+
173
+ from awdit import verify
174
+ result = verify(str(log_path))
175
+ check("the audit chain verifies (no gaps)", bool(result))
176
+ finally:
177
+ if old_log is not None:
178
+ os.environ["AWRUN_AUDIT_LOG"] = old_log
179
+ else:
180
+ os.environ.pop("AWRUN_AUDIT_LOG", None)
181
+
182
+ # A directory that cannot possibly resolve (no such file) must degrade,
183
+ # never raise -- this is the difference between "not authenticated" and
184
+ # a crashed CLI.
185
+ os.environ["AWRUN_IAM_DIRECTORY"] = "/does/not/exist/iam.json"
186
+ try:
187
+ check("an unresolvable IAM directory degrades to None, not an exception",
188
+ resolve_session("anything") is None)
189
+ finally:
190
+ os.environ.pop("AWRUN_IAM_DIRECTORY", None)
191
+
192
+ print("SELF-TEST", "PASS" if ok else "FAIL")
193
+ return 0 if ok else 1
194
+
195
+
196
+ def main() -> int:
197
+ import argparse
198
+ ap = argparse.ArgumentParser(description=__doc__)
199
+ ap.add_argument("--self-test", action="store_true")
200
+ args = ap.parse_args()
201
+ if args.self_test:
202
+ return _self_test()
203
+ ap.print_help()
204
+ return 2
205
+
206
+
207
+ if __name__ == "__main__":
208
+ import sys
209
+ sys.exit(main())