ghost-layer 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.
ghost/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """GHOST — The Spectral Execution Layer for Autonomous Agents.
2
+
3
+ Ephemeral, scoped, signed execution for AI agents. Spawn a short-lived session,
4
+ route the agent's actions through an intercept that records cryptographic
5
+ residue, then evaporate — leaving a tamper-evident audit trail.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+ __author__ = "Timothy Walton (Script Master Labs LLC)"
12
+ __license__ = "MIT"
13
+
14
+ from .session import act, evaporate, replay, spawn # noqa: E402
15
+ from .store import ResidueStore # noqa: E402
16
+ from .proxy import GhostProxy, possess # noqa: E402
17
+
18
+ __all__ = [
19
+ "spawn",
20
+ "act",
21
+ "evaporate",
22
+ "replay",
23
+ "possess",
24
+ "GhostProxy",
25
+ "ResidueStore",
26
+ "__version__",
27
+ ]
ghost/cli.py ADDED
@@ -0,0 +1,206 @@
1
+ """GHOST command-line interface.
2
+
3
+ Give your AI agent a body that vanishes. Thin Click layer over ghost.session.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from typing import Optional
11
+
12
+ import click
13
+
14
+ from . import __version__
15
+ from . import session as _session
16
+ from .session import (
17
+ ExpiredSessionError,
18
+ ScopeError,
19
+ SessionError,
20
+ )
21
+ from .store import ResidueStore
22
+
23
+ GREEN = "green"
24
+ GOLD = "yellow"
25
+ PINK = "magenta"
26
+ CYAN = "cyan"
27
+ RED = "red"
28
+
29
+
30
+ def _emit(obj: dict, color: str) -> None:
31
+ click.secho(json.dumps(obj, indent=2), fg=color)
32
+
33
+
34
+ @click.group()
35
+ @click.version_option(version=__version__, prog_name="ghost")
36
+ @click.option("--db", type=click.Path(), default=None, help="Override residue DB path.")
37
+ @click.pass_context
38
+ def cli(ctx: click.Context, db: Optional[str]) -> None:
39
+ """GHOST — Ephemeral Execution Layer for Autonomous Agents.
40
+
41
+ Declare intent -> spawn -> execute -> evaporate -> leave signed residue.
42
+ """
43
+ ctx.ensure_object(dict)
44
+ ctx.obj["store"] = ResidueStore(db)
45
+
46
+
47
+ @cli.command()
48
+ @click.option("--intent", required=True, help="Human-readable intent, e.g. 'deploy_staging'.")
49
+ @click.option("--ttl", default=300, show_default=True, help="Session lifetime in seconds.")
50
+ @click.option("--scope", multiple=True, help="Allowed tool scope (repeatable).")
51
+ @click.pass_context
52
+ def spawn(ctx: click.Context, intent: str, ttl: int, scope: tuple) -> None:
53
+ """Spawn an ephemeral session with a fresh signing key."""
54
+ store: ResidueStore = ctx.obj["store"]
55
+ result = _session.spawn(store, intent=intent, ttl=ttl, scopes=list(scope))
56
+ _emit(result, GREEN)
57
+ click.secho(
58
+ f"\nEphemeral session spawned: {result['session_id']}", fg=GREEN, bold=True
59
+ )
60
+ click.secho(
61
+ f" TTL {ttl}s | scopes: {', '.join(scope) if scope else '(none)'}", fg=CYAN
62
+ )
63
+
64
+
65
+ @cli.command()
66
+ @click.option("--agent", required=True, help="Agent identifier, e.g. openai://gpt-4.")
67
+ @click.option("--session-id", required=True, help="Session from 'ghost spawn'.")
68
+ @click.option("--port", default=9999, show_default=True, help="Proxy listen port.")
69
+ @click.pass_context
70
+ def possess(ctx: click.Context, agent: str, session_id: str, port: int) -> None:
71
+ """Bind an agent to the session via the local intercept proxy."""
72
+ store: ResidueStore = ctx.obj["store"]
73
+ row = store.get_session(session_id)
74
+ if row is None:
75
+ click.secho(f"Error: session {session_id} not found", fg=RED)
76
+ sys.exit(1)
77
+ if row["evaporated_at"]:
78
+ click.secho(f"Error: session {session_id} already evaporated", fg=RED)
79
+ sys.exit(1)
80
+ click.secho(f"Proxy ready on localhost:{port}", fg=CYAN, bold=True)
81
+ click.secho(f" agent : {agent}", fg=CYAN)
82
+ click.secho(f" session : {session_id}", fg=CYAN)
83
+ click.secho(f" scopes : {row['scopes'] or '(none)'}", fg=CYAN)
84
+ click.secho("\n Route agent HTTP through GhostProxy (see examples/). ", fg=GOLD)
85
+
86
+
87
+ @cli.command()
88
+ @click.option("--tool", required=True, help="Tool name, e.g. aws_ec2.")
89
+ @click.option("--action", required=True, help="Action name, e.g. RunInstances.")
90
+ @click.option("--params", type=click.File("r"), default=None, help="JSON params file.")
91
+ @click.option("--session-id", required=True, help="Target session.")
92
+ @click.option("--no-scope", is_flag=True, help="Disable scope enforcement.")
93
+ @click.pass_context
94
+ def act(
95
+ ctx: click.Context,
96
+ tool: str,
97
+ action: str,
98
+ params: Optional[click.File],
99
+ session_id: str,
100
+ no_scope: bool,
101
+ ) -> None:
102
+ """Record a scoped, signed action against the session."""
103
+ store: ResidueStore = ctx.obj["store"]
104
+ payload = json.load(params) if params else {}
105
+ try:
106
+ result = _session.act(
107
+ store,
108
+ session_id,
109
+ tool=tool,
110
+ action=action,
111
+ params=payload,
112
+ enforce_scope=not no_scope,
113
+ )
114
+ except ScopeError as e:
115
+ click.secho(f"DENIED (scope): {e}", fg=RED)
116
+ sys.exit(2)
117
+ except ExpiredSessionError as e:
118
+ click.secho(f"DENIED (expired): {e}", fg=RED)
119
+ sys.exit(3)
120
+ except SessionError as e:
121
+ click.secho(f"Error: {e}", fg=RED)
122
+ sys.exit(1)
123
+ _emit(result, GREEN)
124
+ click.secho("\n action signed + logged to residue", fg=GREEN)
125
+
126
+
127
+ @cli.command()
128
+ @click.option("--session-id", required=True, help="Session to evaporate.")
129
+ @click.pass_context
130
+ def evaporate(ctx: click.Context, session_id: str) -> None:
131
+ """Destroy the key, sign the chain, finalize the residue."""
132
+ store: ResidueStore = ctx.obj["store"]
133
+ try:
134
+ result = _session.evaporate(store, session_id)
135
+ except SessionError as e:
136
+ click.secho(f"Error: {e}", fg=RED)
137
+ sys.exit(1)
138
+ _emit(result, GOLD)
139
+ click.secho("\n Session evaporated. Ephemeral key shredded.", fg=PINK, bold=True)
140
+
141
+
142
+ @cli.command()
143
+ @click.option("--session-id", default=None, help="Session to replay.")
144
+ @click.option("--all", "all_", is_flag=True, help="List all sessions.")
145
+ @click.option(
146
+ "--format", "fmt", type=click.Choice(["json", "csv"]), default="json", show_default=True
147
+ )
148
+ @click.pass_context
149
+ def replay(ctx: click.Context, session_id: Optional[str], all_: bool, fmt: str) -> None:
150
+ """Retrieve and verify signed execution history."""
151
+ store: ResidueStore = ctx.obj["store"]
152
+ if all_:
153
+ rows = store.list_sessions()
154
+ if fmt == "csv":
155
+ click.echo("session_id,intent,spawned_at,evaporated_at,actions")
156
+ for r in rows:
157
+ n = store.count_actions(r["session_id"])
158
+ click.echo(
159
+ f"{r['session_id']},{r['intent']},{r['spawned_at']},"
160
+ f"{r['evaporated_at'] or ''},{n}"
161
+ )
162
+ else:
163
+ for r in rows:
164
+ _emit(
165
+ {
166
+ "session_id": r["session_id"],
167
+ "intent": r["intent"],
168
+ "spawned_at": r["spawned_at"],
169
+ "evaporated_at": r["evaporated_at"],
170
+ "actions": store.count_actions(r["session_id"]),
171
+ },
172
+ GREEN,
173
+ )
174
+ return
175
+
176
+ if not session_id:
177
+ click.secho("Error: provide --session-id or --all", fg=RED)
178
+ sys.exit(1)
179
+ try:
180
+ result = _session.replay(store, session_id)
181
+ except SessionError as e:
182
+ click.secho(f"Error: {e}", fg=RED)
183
+ sys.exit(1)
184
+
185
+ if fmt == "csv":
186
+ click.echo("seq,tool,action,timestamp,verified")
187
+ for a in result["actions"]:
188
+ click.echo(
189
+ f"{a['seq']},{a['tool']},{a['action']},{a['timestamp']},{a['verified']}"
190
+ )
191
+ click.echo(f"# root_verified={result['root_verified']} verified={result['verified']}")
192
+ else:
193
+ _emit(result, GREEN if result["verified"] else RED)
194
+ if result["verified"]:
195
+ click.secho("\n residue verified", fg=GREEN, bold=True)
196
+ else:
197
+ click.secho("\n RESIDUE VERIFICATION FAILED", fg=RED, bold=True)
198
+ sys.exit(4)
199
+
200
+
201
+ def main() -> None:
202
+ cli(obj={})
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
ghost/crypto.py ADDED
@@ -0,0 +1,62 @@
1
+ """Cryptographic primitives for GHOST.
2
+
3
+ Ed25519 signing of residue entries plus SHA-256 hashing helpers. The signing
4
+ key is generated per-session at spawn time and destroyed at evaporate time;
5
+ only the public (verifying) key is persisted so the audit trail can be verified
6
+ after the session is gone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ from typing import Any
15
+
16
+ from cryptography.exceptions import InvalidSignature
17
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
18
+ Ed25519PrivateKey,
19
+ Ed25519PublicKey,
20
+ )
21
+
22
+
23
+ def generate_keypair() -> tuple[bytes, bytes]:
24
+ """Return (private_seed_32b, public_raw_32b) for a fresh Ed25519 key."""
25
+ sk = Ed25519PrivateKey.generate()
26
+ private_seed = sk.private_bytes_raw()
27
+ public_raw = sk.public_key().public_bytes_raw()
28
+ return private_seed, public_raw
29
+
30
+
31
+ def sign(private_seed: bytes, message: bytes) -> str:
32
+ """Sign message with the private seed; return base64 signature."""
33
+ sk = Ed25519PrivateKey.from_private_bytes(private_seed)
34
+ sig = sk.sign(message)
35
+ return base64.b64encode(sig).decode("ascii")
36
+
37
+
38
+ def verify(public_raw: bytes, message: bytes, signature_b64: str) -> bool:
39
+ """Verify a base64 signature against message using the raw public key."""
40
+ try:
41
+ pk = Ed25519PublicKey.from_public_bytes(public_raw)
42
+ pk.verify(base64.b64decode(signature_b64), message)
43
+ return True
44
+ except (InvalidSignature, ValueError):
45
+ return False
46
+
47
+
48
+ def b64(data: bytes) -> str:
49
+ return base64.b64encode(data).decode("ascii")
50
+
51
+
52
+ def unb64(text: str) -> bytes:
53
+ return base64.b64decode(text)
54
+
55
+
56
+ def sha256_hex(value: str) -> str:
57
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
58
+
59
+
60
+ def canonical_hash(obj: Any) -> str:
61
+ """Deterministic SHA-256 over a JSON-serialisable object."""
62
+ return sha256_hex(json.dumps(obj, sort_keys=True, separators=(",", ":")))
ghost/proxy.py ADDED
@@ -0,0 +1,109 @@
1
+ """Agent-side intercept for GHOST.
2
+
3
+ `possess()` returns a callable proxy that wraps an HTTP transport. Every mutating
4
+ call the agent makes is mapped to a ghost.act() residue entry before the real
5
+ call is dispatched, and the ephemeral session token is injected while any caller
6
+ credentials are stripped. This module has no hard dependency on `requests`; the
7
+ transport is injected, which keeps it unit-testable and SDK-agnostic.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Callable, Optional
13
+
14
+ from . import session as _session
15
+ from .store import ResidueStore
16
+
17
+ MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
18
+
19
+
20
+ class GhostProxy:
21
+ """Intercepts HTTP calls, records signed residue, then dispatches.
22
+
23
+ transport: a callable (method, url, **kwargs) -> response-like object with
24
+ at least a `.status_code` attribute and optional `.json()` method. Inject a
25
+ real adapter (requests/httpx) in production; inject a fake in tests.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ store: ResidueStore,
31
+ session_id: str,
32
+ transport: Callable[..., Any],
33
+ token: str,
34
+ enforce_scope: bool = True,
35
+ ) -> None:
36
+ self.store = store
37
+ self.session_id = session_id
38
+ self.transport = transport
39
+ self.token = token
40
+ self.enforce_scope = enforce_scope
41
+
42
+ def request(
43
+ self,
44
+ method: str,
45
+ url: str,
46
+ tool: str,
47
+ action: str,
48
+ params: Optional[dict[str, Any]] = None,
49
+ headers: Optional[dict[str, str]] = None,
50
+ **kwargs: Any,
51
+ ) -> Any:
52
+ method = method.upper()
53
+ headers = dict(headers or {})
54
+
55
+ # Strip any caller-supplied auth; inject the ephemeral ghost token.
56
+ for h in list(headers):
57
+ if h.lower() in {"authorization", "x-api-key", "api-key"}:
58
+ headers.pop(h)
59
+ headers["X-Ghost-Token"] = self.token
60
+ headers["X-Ghost-Session"] = self.session_id
61
+
62
+ # Reads are dispatched without a residue entry unless mutating.
63
+ if method not in MUTATING_METHODS:
64
+ return self.transport(method, url, headers=headers, **kwargs)
65
+
66
+ # Record intent BEFORE dispatch so a crash still leaves a trace.
67
+ record = _session.act(
68
+ self.store,
69
+ self.session_id,
70
+ tool=tool,
71
+ action=action,
72
+ params=params or {"url": url, "method": method},
73
+ response=None,
74
+ http_status=0,
75
+ enforce_scope=self.enforce_scope,
76
+ )
77
+
78
+ resp = self.transport(method, url, headers=headers, **kwargs)
79
+ status = getattr(resp, "status_code", 0)
80
+
81
+ # Post-dispatch we record the response as a follow-on signed entry.
82
+ body: Optional[dict[str, Any]] = None
83
+ json_fn = getattr(resp, "json", None)
84
+ if callable(json_fn):
85
+ try:
86
+ body = json_fn()
87
+ except Exception:
88
+ body = None
89
+ _session.act(
90
+ self.store,
91
+ self.session_id,
92
+ tool=tool,
93
+ action=f"{action}:response",
94
+ params={"action_id": record["action_id"]},
95
+ response=body if isinstance(body, dict) else {"status": status},
96
+ http_status=status,
97
+ enforce_scope=False, # response logging is never scope-blocked
98
+ )
99
+ return resp
100
+
101
+
102
+ def possess(
103
+ store: ResidueStore,
104
+ session_id: str,
105
+ transport: Callable[..., Any],
106
+ token: str,
107
+ enforce_scope: bool = True,
108
+ ) -> GhostProxy:
109
+ return GhostProxy(store, session_id, transport, token, enforce_scope)
ghost/session.py ADDED
@@ -0,0 +1,307 @@
1
+ """Session lifecycle for GHOST.
2
+
3
+ Pure logic layer (no CLI, no I/O formatting) so it is unit-testable. Ephemeral
4
+ private keys live on disk under GHOST_HOME/sessions/<id>/ for the lifetime of
5
+ the session and are shredded at evaporate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import secrets
12
+ import shutil
13
+ from datetime import datetime, timedelta, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from . import crypto
18
+ from .store import GHOST_HOME, ResidueStore
19
+
20
+ SESSIONS_DIR = GHOST_HOME / "sessions"
21
+
22
+
23
+ def _now() -> datetime:
24
+ return datetime.now(timezone.utc)
25
+
26
+
27
+ def _iso(dt: datetime) -> str:
28
+ return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
29
+
30
+
31
+ def _parse_iso(text: str) -> datetime:
32
+ return datetime.strptime(text, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc)
33
+
34
+
35
+ def _session_dir(session_id: str) -> Path:
36
+ return SESSIONS_DIR / session_id
37
+
38
+
39
+ def _fingerprint(public_raw: bytes) -> str:
40
+ return crypto.sha256_hex(crypto.b64(public_raw))[:16]
41
+
42
+
43
+ class SessionError(Exception):
44
+ pass
45
+
46
+
47
+ class ExpiredSessionError(SessionError):
48
+ pass
49
+
50
+
51
+ class ScopeError(SessionError):
52
+ pass
53
+
54
+
55
+ def spawn(
56
+ store: ResidueStore,
57
+ intent: str,
58
+ ttl: int = 300,
59
+ scopes: Optional[list[str]] = None,
60
+ ) -> dict[str, Any]:
61
+ """Create an ephemeral session and persist a session record."""
62
+ scopes = scopes or []
63
+ session_id = "gh_" + secrets.token_hex(16)
64
+
65
+ private_seed, public_raw = crypto.generate_keypair()
66
+ sdir = _session_dir(session_id)
67
+ sdir.mkdir(parents=True, exist_ok=True)
68
+ key_path = sdir / "ed25519_seed"
69
+ # 0600 so other users on the box cannot read the ephemeral key
70
+ fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
71
+ with os.fdopen(fd, "wb") as fh:
72
+ fh.write(private_seed)
73
+
74
+ spawned = _now()
75
+ expires = spawned + timedelta(seconds=ttl)
76
+ public_b64 = crypto.b64(public_raw)
77
+
78
+ store.insert_session(
79
+ {
80
+ "session_id": session_id,
81
+ "intent": intent,
82
+ "scopes": ",".join(scopes),
83
+ "public_key": public_b64,
84
+ "spawned_at": _iso(spawned),
85
+ "ttl_seconds": ttl,
86
+ "expires_at": _iso(expires),
87
+ }
88
+ )
89
+ store.log_credential(session_id, _fingerprint(public_raw), "spawn", _iso(spawned))
90
+
91
+ return {
92
+ "session_id": session_id,
93
+ "intent": intent,
94
+ "scopes": scopes,
95
+ "public_key": public_b64,
96
+ "spawned_at": _iso(spawned),
97
+ "expires_at": _iso(expires),
98
+ "ttl_seconds": ttl,
99
+ }
100
+
101
+
102
+ def _load_seed(session_id: str) -> bytes:
103
+ key_path = _session_dir(session_id) / "ed25519_seed"
104
+ if not key_path.exists():
105
+ raise SessionError(f"ephemeral key for {session_id} not found (already evaporated?)")
106
+ return key_path.read_bytes()
107
+
108
+
109
+ def act(
110
+ store: ResidueStore,
111
+ session_id: str,
112
+ tool: str,
113
+ action: str,
114
+ params: Optional[dict[str, Any]] = None,
115
+ response: Optional[dict[str, Any]] = None,
116
+ http_status: int = 200,
117
+ enforce_scope: bool = True,
118
+ ) -> dict[str, Any]:
119
+ """Record a scoped, signed action against a live session."""
120
+ row = store.get_session(session_id)
121
+ if row is None:
122
+ raise SessionError(f"session {session_id} not found")
123
+ if row["evaporated_at"]:
124
+ raise ExpiredSessionError(f"session {session_id} already evaporated")
125
+ if _now() > _parse_iso(row["expires_at"]):
126
+ raise ExpiredSessionError(f"session {session_id} TTL expired at {row['expires_at']}")
127
+
128
+ scopes = [s for s in (row["scopes"] or "").split(",") if s]
129
+ if enforce_scope and scopes and tool not in scopes:
130
+ raise ScopeError(f"tool '{tool}' not in session scopes {scopes}")
131
+
132
+ params = params or {}
133
+ seed = _load_seed(session_id)
134
+ seq = store.next_seq(session_id)
135
+ action_id = "act_" + secrets.token_hex(8)
136
+ ts = _iso(_now())
137
+
138
+ params_hash = crypto.canonical_hash(params)
139
+ response_hash = crypto.canonical_hash(response) if response is not None else None
140
+
141
+ # The signed payload binds order, identity, tool, action, and data hashes.
142
+ payload = {
143
+ "session_id": session_id,
144
+ "seq": seq,
145
+ "action_id": action_id,
146
+ "tool": tool,
147
+ "action": action,
148
+ "params_hash": params_hash,
149
+ "response_hash": response_hash,
150
+ "http_status": http_status,
151
+ "timestamp": ts,
152
+ }
153
+ signature = crypto.sign(seed, crypto.canonical_hash(payload).encode())
154
+
155
+ store.insert_action(
156
+ {
157
+ "action_id": action_id,
158
+ "session_id": session_id,
159
+ "seq": seq,
160
+ "tool": tool,
161
+ "action": action,
162
+ "params_hash": params_hash,
163
+ "response_hash": response_hash,
164
+ "http_status": http_status,
165
+ "decision": "executed",
166
+ "timestamp": ts,
167
+ "signature": signature,
168
+ }
169
+ )
170
+
171
+ return {
172
+ "action_id": action_id,
173
+ "session_id": session_id,
174
+ "seq": seq,
175
+ "tool": tool,
176
+ "action": action,
177
+ "params_hash": params_hash,
178
+ "response_hash": response_hash,
179
+ "http_status": http_status,
180
+ "timestamp": ts,
181
+ "signature": signature,
182
+ }
183
+
184
+
185
+ def evaporate(store: ResidueStore, session_id: str) -> dict[str, Any]:
186
+ """Destroy the ephemeral key, sign the action chain, finalize the record."""
187
+ row = store.get_session(session_id)
188
+ if row is None:
189
+ raise SessionError(f"session {session_id} not found")
190
+ if row["evaporated_at"]:
191
+ raise SessionError(f"session {session_id} already evaporated")
192
+
193
+ seed = _load_seed(session_id)
194
+ actions = store.actions_for(session_id)
195
+ chain = [
196
+ {
197
+ "seq": a["seq"],
198
+ "action_id": a["action_id"],
199
+ "signature": a["signature"],
200
+ }
201
+ for a in actions
202
+ ]
203
+ root_signature = crypto.sign(
204
+ seed, crypto.canonical_hash({"session_id": session_id, "chain": chain}).encode()
205
+ )
206
+
207
+ evaporated = _now()
208
+ spawned = _parse_iso(row["spawned_at"])
209
+ lived = (evaporated - spawned).total_seconds()
210
+
211
+ store.finalize_session(session_id, _iso(evaporated), lived, root_signature)
212
+ store.log_credential(
213
+ session_id,
214
+ _fingerprint(crypto.unb64(row["public_key"])),
215
+ "evaporate",
216
+ _iso(evaporated),
217
+ )
218
+
219
+ # Shred the key material and remove the session directory.
220
+ sdir = _session_dir(session_id)
221
+ key_path = sdir / "ed25519_seed"
222
+ if key_path.exists():
223
+ with open(key_path, "wb") as fh:
224
+ fh.write(secrets.token_bytes(len(seed)))
225
+ fh.flush()
226
+ os.fsync(fh.fileno())
227
+ if sdir.exists():
228
+ shutil.rmtree(sdir)
229
+
230
+ return {
231
+ "session_id": session_id,
232
+ "intent": row["intent"],
233
+ "status": "evaporated",
234
+ "lived_for_seconds": round(lived, 3),
235
+ "actions_executed": len(actions),
236
+ "root_signature": root_signature,
237
+ "evaporated_at": _iso(evaporated),
238
+ }
239
+
240
+
241
+ def replay(store: ResidueStore, session_id: str) -> dict[str, Any]:
242
+ """Return the full session record plus verification result."""
243
+ row = store.get_session(session_id)
244
+ if row is None:
245
+ raise SessionError(f"session {session_id} not found")
246
+
247
+ public_raw = crypto.unb64(row["public_key"])
248
+ actions = store.actions_for(session_id)
249
+
250
+ verified_actions = []
251
+ all_ok = True
252
+ for a in actions:
253
+ payload = {
254
+ "session_id": session_id,
255
+ "seq": a["seq"],
256
+ "action_id": a["action_id"],
257
+ "tool": a["tool"],
258
+ "action": a["action"],
259
+ "params_hash": a["params_hash"],
260
+ "response_hash": a["response_hash"],
261
+ "http_status": a["http_status"],
262
+ "timestamp": a["timestamp"],
263
+ }
264
+ ok = crypto.verify(public_raw, crypto.canonical_hash(payload).encode(), a["signature"])
265
+ all_ok = all_ok and ok
266
+ verified_actions.append(
267
+ {
268
+ "seq": a["seq"],
269
+ "action_id": a["action_id"],
270
+ "tool": a["tool"],
271
+ "action": a["action"],
272
+ "params_hash": a["params_hash"],
273
+ "response_hash": a["response_hash"],
274
+ "http_status": a["http_status"],
275
+ "timestamp": a["timestamp"],
276
+ "signature": a["signature"],
277
+ "verified": ok,
278
+ }
279
+ )
280
+
281
+ root_ok = None
282
+ if row["root_signature"]:
283
+ chain = [
284
+ {"seq": a["seq"], "action_id": a["action_id"], "signature": a["signature"]}
285
+ for a in actions
286
+ ]
287
+ root_ok = crypto.verify(
288
+ public_raw,
289
+ crypto.canonical_hash({"session_id": session_id, "chain": chain}).encode(),
290
+ row["root_signature"],
291
+ )
292
+ all_ok = all_ok and root_ok
293
+
294
+ return {
295
+ "session_id": session_id,
296
+ "intent": row["intent"],
297
+ "scopes": [s for s in (row["scopes"] or "").split(",") if s],
298
+ "spawned_at": row["spawned_at"],
299
+ "expires_at": row["expires_at"],
300
+ "evaporated_at": row["evaporated_at"],
301
+ "lived_for_seconds": row["lived_seconds"],
302
+ "public_key": row["public_key"],
303
+ "actions": verified_actions,
304
+ "root_signature": row["root_signature"],
305
+ "root_verified": root_ok,
306
+ "verified": all_ok,
307
+ }
ghost/store.py ADDED
@@ -0,0 +1,148 @@
1
+ """SQLite residue store for GHOST.
2
+
3
+ Append-only audit log of sessions and the actions executed within them. Each
4
+ action carries a hash of its params/response and an Ed25519 signature. At
5
+ evaporate time a root signature is computed over the ordered action chain,
6
+ making any later tampering detectable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sqlite3
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ GHOST_HOME = Path(os.environ.get("GHOST_HOME", Path.home() / ".ghost"))
17
+ DEFAULT_DB = GHOST_HOME / "residue.db"
18
+
19
+ _SCHEMA = """
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ session_id TEXT PRIMARY KEY,
22
+ intent TEXT NOT NULL,
23
+ scopes TEXT NOT NULL DEFAULT '',
24
+ public_key TEXT NOT NULL,
25
+ spawned_at TEXT NOT NULL,
26
+ ttl_seconds INTEGER NOT NULL,
27
+ expires_at TEXT NOT NULL,
28
+ evaporated_at TEXT,
29
+ lived_seconds REAL,
30
+ root_signature TEXT
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS actions (
34
+ action_id TEXT PRIMARY KEY,
35
+ session_id TEXT NOT NULL,
36
+ seq INTEGER NOT NULL,
37
+ tool TEXT NOT NULL,
38
+ action TEXT NOT NULL,
39
+ params_hash TEXT NOT NULL,
40
+ response_hash TEXT,
41
+ http_status INTEGER,
42
+ decision TEXT NOT NULL DEFAULT 'executed',
43
+ timestamp TEXT NOT NULL,
44
+ signature TEXT NOT NULL,
45
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS credentials_log (
49
+ session_id TEXT NOT NULL,
50
+ key_fingerprint TEXT NOT NULL,
51
+ event TEXT NOT NULL,
52
+ timestamp TEXT NOT NULL,
53
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id)
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_sessions_spawned ON sessions(spawned_at DESC);
57
+ CREATE INDEX IF NOT EXISTS idx_actions_session ON actions(session_id, seq);
58
+ """
59
+
60
+
61
+ class ResidueStore:
62
+ def __init__(self, db_path: Optional[Path] = None) -> None:
63
+ self.db_path = Path(db_path) if db_path else DEFAULT_DB
64
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
65
+ self._conn = sqlite3.connect(self.db_path)
66
+ self._conn.row_factory = sqlite3.Row
67
+ self._conn.execute("PRAGMA foreign_keys = ON")
68
+ self._conn.executescript(_SCHEMA)
69
+ self._conn.commit()
70
+
71
+ # ---- sessions ---------------------------------------------------------
72
+ def insert_session(self, row: dict[str, Any]) -> None:
73
+ self._conn.execute(
74
+ """INSERT INTO sessions
75
+ (session_id, intent, scopes, public_key, spawned_at,
76
+ ttl_seconds, expires_at)
77
+ VALUES (:session_id, :intent, :scopes, :public_key, :spawned_at,
78
+ :ttl_seconds, :expires_at)""",
79
+ row,
80
+ )
81
+ self._conn.commit()
82
+
83
+ def get_session(self, session_id: str) -> Optional[sqlite3.Row]:
84
+ cur = self._conn.execute(
85
+ "SELECT * FROM sessions WHERE session_id = ?", (session_id,)
86
+ )
87
+ return cur.fetchone()
88
+
89
+ def finalize_session(
90
+ self, session_id: str, evaporated_at: str, lived_seconds: float, root_signature: str
91
+ ) -> None:
92
+ self._conn.execute(
93
+ """UPDATE sessions
94
+ SET evaporated_at = ?, lived_seconds = ?, root_signature = ?
95
+ WHERE session_id = ?""",
96
+ (evaporated_at, lived_seconds, root_signature, session_id),
97
+ )
98
+ self._conn.commit()
99
+
100
+ def list_sessions(self, limit: int = 50) -> list[sqlite3.Row]:
101
+ cur = self._conn.execute(
102
+ "SELECT * FROM sessions ORDER BY spawned_at DESC LIMIT ?", (limit,)
103
+ )
104
+ return cur.fetchall()
105
+
106
+ # ---- actions ----------------------------------------------------------
107
+ def next_seq(self, session_id: str) -> int:
108
+ cur = self._conn.execute(
109
+ "SELECT COALESCE(MAX(seq), 0) + 1 AS n FROM actions WHERE session_id = ?",
110
+ (session_id,),
111
+ )
112
+ return int(cur.fetchone()["n"])
113
+
114
+ def insert_action(self, row: dict[str, Any]) -> None:
115
+ self._conn.execute(
116
+ """INSERT INTO actions
117
+ (action_id, session_id, seq, tool, action, params_hash,
118
+ response_hash, http_status, decision, timestamp, signature)
119
+ VALUES (:action_id, :session_id, :seq, :tool, :action, :params_hash,
120
+ :response_hash, :http_status, :decision, :timestamp, :signature)""",
121
+ row,
122
+ )
123
+ self._conn.commit()
124
+
125
+ def actions_for(self, session_id: str) -> list[sqlite3.Row]:
126
+ cur = self._conn.execute(
127
+ "SELECT * FROM actions WHERE session_id = ? ORDER BY seq ASC",
128
+ (session_id,),
129
+ )
130
+ return cur.fetchall()
131
+
132
+ def count_actions(self, session_id: str) -> int:
133
+ cur = self._conn.execute(
134
+ "SELECT COUNT(*) AS c FROM actions WHERE session_id = ?", (session_id,)
135
+ )
136
+ return int(cur.fetchone()["c"])
137
+
138
+ # ---- credentials ------------------------------------------------------
139
+ def log_credential(self, session_id: str, fingerprint: str, event: str, ts: str) -> None:
140
+ self._conn.execute(
141
+ """INSERT INTO credentials_log (session_id, key_fingerprint, event, timestamp)
142
+ VALUES (?, ?, ?, ?)""",
143
+ (session_id, fingerprint, event, ts),
144
+ )
145
+ self._conn.commit()
146
+
147
+ def close(self) -> None:
148
+ self._conn.close()
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghost-layer
3
+ Version: 0.1.0
4
+ Summary: Ephemeral execution layer for autonomous AI agents — scoped credentials, cryptographic residue.
5
+ Author-email: Timothy Walton <ScriptMasterLabs@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://scriptmasterlabs.com/stack/ghost
8
+ Project-URL: Repository, https://github.com/timwal78/ghost-layer
9
+ Project-URL: Documentation, https://github.com/timwal78/ghost-layer/blob/main/docs/ARCHITECTURE.md
10
+ Project-URL: Issues, https://github.com/timwal78/ghost-layer/issues
11
+ Keywords: ai-agents,autonomous,ephemeral,credentials,cryptography,audit,x402
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Security :: Cryptography
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: click>=8.0
26
+ Requires-Dist: cryptography>=41.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0; extra == "dev"
29
+ Requires-Dist: build; extra == "dev"
30
+ Requires-Dist: twine; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # GHOST
34
+ ### The Spectral Execution Layer for Autonomous Agents
35
+
36
+ > *"Most agents die in the light. Ours operate in the dark."*
37
+
38
+ Give your AI agent a **body that vanishes**. GHOST is an ephemeral execution
39
+ layer: an agent declares intent, spawns a short-lived signing key, executes
40
+ **scoped** actions through an intercept that records **cryptographic residue**,
41
+ then evaporates — leaving a tamper-evident audit trail and **zero standing
42
+ credentials**.
43
+
44
+ [![status](https://img.shields.io/badge/status-alpha-39FF14)](https://github.com/timwal78/ghost-layer)
45
+ [![license](https://img.shields.io/badge/license-MIT-FFD700)](LICENSE)
46
+ [![python](https://img.shields.io/badge/python-3.9%2B-FF1493)](pyproject.toml)
47
+
48
+ ---
49
+
50
+ ## The Problem
51
+
52
+ You gave your agent AWS keys. Now you're watching CloudTrail at 3am.
53
+
54
+ Agents are **ephemeral bursts of intent**. Humans are persistent. Yet today's
55
+ agents execute with persistent, human-shaped credentials and unbounded scope.
56
+ One leaked key compromises everything, and there's no signed proof of *why* the
57
+ agent did what it did.
58
+
59
+ ## The Inversion
60
+
61
+ | Human model | Agent model (GHOST) |
62
+ |---|---|
63
+ | log in → do stuff → log out | declare intent → **spawn** → execute → **evaporate** → leave **residue** |
64
+ | session persists | session auto-expires (TTL) |
65
+ | broad standing access | scoped to declared tools |
66
+ | audit logs (unsigned) | Ed25519-signed, tamper-evident chain |
67
+
68
+ ---
69
+
70
+ ## The Ritual (Quickstart)
71
+
72
+ ```bash
73
+ pip install ghost-layer
74
+
75
+ ghost spawn --intent "deploy_staging" --ttl 300 --scope aws_ec2
76
+ ghost act --tool aws_ec2 --action RunInstances --session-id gh_9ddb...
77
+ ghost evaporate --session-id gh_9ddb...
78
+ ghost replay --session-id gh_9ddb...
79
+ ```
80
+
81
+ What just happened: your agent never held a standing credential. The session
82
+ lived under a second. The residue is **Ed25519-signed** and immutable — replay
83
+ it and verify exactly why that instance spawned.
84
+
85
+ Out-of-scope calls are refused before they run:
86
+
87
+ ```bash
88
+ ghost act --tool stripe --action CreateCharge --session-id gh_9ddb...
89
+ # DENIED (scope): tool 'stripe' not in session scopes ['aws_ec2'] (exit 2)
90
+ ```
91
+
92
+ ---
93
+
94
+ ## Five Commands
95
+
96
+ | Command | What it does |
97
+ |---|---|
98
+ | `ghost spawn` | Mint an ephemeral session + fresh Ed25519 keypair, TTL countdown begins |
99
+ | `ghost possess` | Bind an agent to the session via the intercept proxy |
100
+ | `ghost act` | Record a **scoped, signed** action (blocked if out-of-scope or expired) |
101
+ | `ghost evaporate` | Shred the key, sign the whole action chain, finalize the residue |
102
+ | `ghost replay` | Re-verify every signature + the root chain signature |
103
+
104
+ ---
105
+
106
+ ## Use It In Code (SDK-agnostic)
107
+
108
+ The transport is injected, so GHOST wraps **any** HTTP client or agent
109
+ framework — LangChain, the OpenAI SDK, raw `requests`/`httpx`:
110
+
111
+ ```python
112
+ from ghost import spawn, possess, evaporate, replay
113
+ from ghost.store import ResidueStore
114
+ import requests
115
+
116
+ store = ResidueStore()
117
+ session = spawn(store, intent="enrich_lead", ttl=120, scopes=["httpbin"])
118
+
119
+ def transport(method, url, headers=None, **kw):
120
+ return requests.request(method, url, headers=headers, **kw)
121
+
122
+ proxy = possess(store, session["session_id"], transport, token="ghtok_demo")
123
+
124
+ # Auth headers the agent sets are STRIPPED; the ghost token is injected.
125
+ proxy.request("POST", "https://httpbin.org/post",
126
+ tool="httpbin", action="submit",
127
+ headers={"Authorization": "Bearer WILL_BE_STRIPPED"})
128
+
129
+ evaporate(store, session["session_id"])
130
+ print(replay(store, session["session_id"])["verified"]) # True
131
+ ```
132
+
133
+ See [`examples/`](examples/) for a LangChain-style agent and an
134
+ **x402 / XRPL payment agent** that settles a micropayment through a single
135
+ 60-second ghost body.
136
+
137
+ ---
138
+
139
+ ## Why It's Tamper-Evident
140
+
141
+ Every action is signed over a canonical hash binding `session_id`, sequence,
142
+ tool, action, and the hashes of params/response. At `evaporate`, a **root
143
+ signature** covers the ordered chain. Change one byte of the residue and
144
+ `ghost replay` reports `verified: false`. The private key is gone by then — it
145
+ **cannot** be re-signed.
146
+
147
+ ```text
148
+ spawn ─▶ keypair (priv on disk 0600, pub in residue)
149
+
150
+ ├─ act ─▶ sign(payload) ─▶ residue row
151
+ ├─ act ─▶ sign(payload) ─▶ residue row
152
+
153
+ evaporate ─▶ sign(chain) = root_sig ─▶ shred priv key
154
+
155
+ replay ─▶ verify(each) + verify(root) ✓ tamper-evident
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Where It Fits
161
+
162
+ GHOST is the execution-safety layer beneath the **x402 agentic web**. It lets
163
+ autonomous agents trigger payment rails ([NEXUS-402](https://nexus-402.com),
164
+ 402Proof, XAH Portal) and act on [SqueezeOS](https://scriptmasterlabs.com)
165
+ signals **without ever holding a standing key** — every autonomous move leaves
166
+ a signed receipt.
167
+
168
+ Full catalog: **[scriptmasterlabs.com/stack](https://scriptmasterlabs.com/stack)**
169
+
170
+ ---
171
+
172
+ ## Status & Roadmap
173
+
174
+ - [x] Python CLI — spawn / possess / act / evaporate / replay
175
+ - [x] SQLite residue store, Ed25519 signing, scope + TTL enforcement
176
+ - [x] Intercept proxy (SDK-agnostic), tamper-detection tests (17 passing)
177
+ - [ ] Rust proxy core (performance)
178
+ - [ ] Native LangChain / OpenAI tool wrappers
179
+ - [ ] Pre/post validation hooks, webhook notifications
180
+ - [ ] Optional cloud-hosted proxy
181
+
182
+ ---
183
+
184
+ ## Install from source
185
+
186
+ ```bash
187
+ git clone https://github.com/timwal78/ghost-layer
188
+ cd ghost-layer
189
+ pip install -e ".[dev]"
190
+ pytest -q # 17 passed
191
+ ```
192
+
193
+ ---
194
+
195
+ Built by **Script Master Labs LLC** · Disabled U.S. Army Veteran–Owned (SDVOSB) · Kinston, NC
196
+ Docs: [ARCHITECTURE.md](docs/ARCHITECTURE.md) · [SECURITY.md](docs/SECURITY.md) · MIT License
@@ -0,0 +1,12 @@
1
+ ghost/__init__.py,sha256=iNtWEHjRZi7XsQ8PBMUYLVFTVyRnDFVYWVvfdNb-BYc,731
2
+ ghost/cli.py,sha256=Pxk4EisY1RHh8HH4HRoF3BGJZVasUA_VY8kdK1J0qzw,7111
3
+ ghost/crypto.py,sha256=jjiHwZEsIPbVJibMzJtoRDL1bk2_lWQ1mYb8NmwSWag,1923
4
+ ghost/proxy.py,sha256=AaTP3wj5NVkyjgtDI39b033otCPPHTsZriPyefRw4oE,3598
5
+ ghost/session.py,sha256=g-CSSrqvF7UC9ClAahPFkwmwbXZ4TYkbhUrXSzTNkn4,9369
6
+ ghost/store.py,sha256=IiyW9zAGpJam5-0DCJVbcpQaZGjDwGAasW_igXDFBhg,5396
7
+ ghost_layer-0.1.0.dist-info/licenses/LICENSE,sha256=emBVfKHqKE_XRCXAgZUcCPHT0bZKXSDGLmSxxMU3100,1096
8
+ ghost_layer-0.1.0.dist-info/METADATA,sha256=wNmMlxOaIqRnwJj7ukBIdoLYzratNJipKTGDnAMzl6Y,6997
9
+ ghost_layer-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
10
+ ghost_layer-0.1.0.dist-info/entry_points.txt,sha256=2wrE4K_rV0tiQ94jZ-g-ifQid6iFQzVeBUWKut2XKuw,41
11
+ ghost_layer-0.1.0.dist-info/top_level.txt,sha256=frV3IWB1-bLZC7XzliilZbAe-3CweZiXnQ8dy57Xpf8,6
12
+ ghost_layer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghost = ghost.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timothy Walton / Script Master Labs LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ ghost