diff --git a/Makefile b/Makefile index 33356f6..baf6333 100644 --- a/Makefile +++ b/Makefile @@ -212,9 +212,10 @@ bounded-execution-admission-check: ## Validate the example admissions (read-only execution-attribution-receipt-test: ## Receipt schema v0.1 validator; fixtures match the executor (NK-WP-0056-T15) python3 -m pytest tools/execution-attribution-receipt/tests -agent-status-token: ## Founder, once per working session: a 1 h read-only token for the agent (NK-WP-0058-T05) +agent-status-token: ## Founder, once per working day: a 12 h read-only token for the agent (NK-WP-0058-T05) @umask 077 && kubectl --kubeconfig $(HOME)/.kube/config-railiance01 -n sso create token nk-agent-status \ - --duration=1h > $(HOME)/.kube/nk-agent-status.token && echo "agent-status-token: valid 1h" + --duration=12h > $(HOME)/.kube/nk-agent-status.token && \ + python3 tools/agent-status/token_expiry.py $(HOME)/.kube/nk-agent-status.token conventions-lint: ## Runbook and tooling conventions from the 2026-11-04/05 pilot (NK-WP-0058-T07), read-only python3 tools/conventions/lint.py diff --git a/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md index 918475d..0a2d320 100644 --- a/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md +++ b/docs/proposals/2026-11-06-nk58-t05-agent-status-grant/README.md @@ -54,7 +54,8 @@ need, and the agent would still act as ops-mason. literal secret values (only file paths, references and URLs). `tools/contract-tests/tests/test_agent_status_grant.py` keeps it that way and keeps the grant read-only and narrow. -**Tokens:** at most 1 hour (SI-14), minted by the founder once per working session. This is read-only, so it isn't a +**Tokens:** 12 hours since 2026-11-06, minted by the founder once per working day. That is an exception to SI-14's +60-minute cap for this read-only identity only (founder decision `65d8aed4`). This is read-only, so it isn't a warden lane: `make agent-status-token` (founder steps below). The agent's kubeconfig `~/.kube/config-agent-status` names the cluster and `tokenFile: ~/.kube/nk-agent-status.token`. @@ -97,8 +98,8 @@ strategy, prune and force off. From `~/railiance-platform` after `git pull --ff- kubectl --kubeconfig $A config use-context nk-agent-status >/dev/null && chmod 600 $A && echo agent-kubeconfig-ok ``` -3. **Each working session:** `make agent-status-token` mints a 1 h token into `~/.kube/nk-agent-status.token` - (mode 600) and prints one line. +3. **Each working day:** `make agent-status-token` mints a 12 h token into `~/.kube/nk-agent-status.token` + (mode 600) and prints its real expiry, read from the token's `exp` claim. The API server may cap a duration. The agent then verifies with `kubectl --kubeconfig ~/.kube/config-agent-status auth can-i --list` and the two negative checks: Secrets and pod logs in `sso` answer `no`. diff --git a/tools/agent-status/tests/test_token_expiry.py b/tools/agent-status/tests/test_token_expiry.py new file mode 100644 index 0000000..f11f2c4 --- /dev/null +++ b/tools/agent-status/tests/test_token_expiry.py @@ -0,0 +1,38 @@ +"""token_expiry.py on synthetic JWTs (NK-WP-0058-T05): only the expiry is printed.""" +import base64 +import datetime as dt +import importlib.util +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('token_expiry', REPO / 'tools/agent-status/token_expiry.py') +te = importlib.util.module_from_spec(spec) +spec.loader.exec_module(te) + + +def jwt(exp): + enc = lambda d: base64.urlsafe_b64encode(json.dumps(d).encode()).decode().rstrip('=') + return f"{enc({'alg': 'RS256'})}.{enc({'exp': exp, 'sub': 'synthetic'})}.c2lnbmF0dXJl" + + +def test_prints_expiry_only(tmp_path, capsys): + exp = int((dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=12)).timestamp()) + token = tmp_path / 't' + token.write_text(jwt(exp) + '\n') + assert te.main([str(token)]) == 0 + out = capsys.readouterr().out + assert out.startswith('agent-status-token: valid until ') + assert '(12.0 h)' in out or '(11.9 h)' in out + assert jwt(exp) not in out and 'synthetic' not in out + + +def test_expired_missing_and_malformed_fail(tmp_path, capsys): + old = tmp_path / 'old' + old.write_text(jwt(int(dt.datetime(2020, 1, 1, tzinfo=dt.timezone.utc).timestamp()))) + assert te.main([str(old)]) == 1 + bad = tmp_path / 'bad' + bad.write_text('not-a-token') + assert te.main([str(bad)]) == 1 + assert te.main([str(tmp_path / 'absent')]) == 1 + assert 'not-a-token' not in capsys.readouterr().out diff --git a/tools/agent-status/token_expiry.py b/tools/agent-status/token_expiry.py new file mode 100644 index 0000000..5d354fb --- /dev/null +++ b/tools/agent-status/token_expiry.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Print when the agent status token expires, never the token (NK-WP-0058-T05). + + python3 tools/agent-status/token_expiry.py ~/.kube/nk-agent-status.token + +Reads only the JWT's `exp` claim; the signature and every other claim are ignored and nothing else is printed. +The API server may cap a requested duration, so this reports the expiry the token really has. Exit 1 when the file +is missing, malformed or already expired. +""" +import base64 +import datetime as dt +import json +import sys +from pathlib import Path + + +def expiry(path): + parts = Path(path).read_text().strip().split('.') + if len(parts) != 3: + raise ValueError('not_a_jwt') + payload = parts[1] + '=' * (-len(parts[1]) % 4) + return dt.datetime.fromtimestamp(int(json.loads(base64.urlsafe_b64decode(payload))['exp']), dt.timezone.utc) + + +def main(argv=None): + path = (argv if argv is not None else sys.argv[1:])[0] + try: + exp = expiry(path) + except (OSError, ValueError, KeyError): + print('agent-status-token: unreadable') + return 1 + left = exp - dt.datetime.now(dt.timezone.utc) + hours = left.total_seconds() / 3600 + print(f'agent-status-token: valid until {exp:%Y-%m-%d %H:%M}Z ({hours:.1f} h)') + return 0 if hours > 0 else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/contract-tests/run.sh b/tools/contract-tests/run.sh index 00110ca..e2e7916 100755 --- a/tools/contract-tests/run.sh +++ b/tools/contract-tests/run.sh @@ -17,6 +17,7 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -rs -p no:cacheprovider \ sso-mfa/k8s/bounded-executor \ tools/contract-tests/tests \ tools/conventions/tests \ + tools/agent-status/tests \ "$@" | tee "$out" if grep -q '^SKIPPED' "$out"; then echo "contract-test: skipped tests above; every contract must run (are the owner checkouts present?)" >&2 diff --git a/workplans/NK-WP-0058-bounded-execution-operability.md b/workplans/NK-WP-0058-bounded-execution-operability.md index 5d1989a..ae2821c 100644 --- a/workplans/NK-WP-0058-bounded-execution-operability.md +++ b/workplans/NK-WP-0058-bounded-execution-operability.md @@ -487,6 +487,8 @@ net-kingdom's copy of the grant is deleted: its contract test reads the platform - Secrets, pod logs, exec, writes, other objects and other namespaces are refused; - Job, rollout and ExternalSecret status, the live routing, and the informed-decision Application state are readable. +- **Token lifetime changed to 12 h** (founder, 2026-11-06, decision `65d8aed4`; an exception to SI-14 for this + read-only identity). `make agent-status-token` prints the real expiry. - **The first reads showed O1 live:** the informed-decision Application pins `005709a` and is `OutOfSync` with git (`77161fe`). That is reported to railiance-platform.