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 +27 -0
- ghost/cli.py +206 -0
- ghost/crypto.py +62 -0
- ghost/proxy.py +109 -0
- ghost/session.py +307 -0
- ghost/store.py +148 -0
- ghost_layer-0.1.0.dist-info/METADATA +196 -0
- ghost_layer-0.1.0.dist-info/RECORD +12 -0
- ghost_layer-0.1.0.dist-info/WHEEL +5 -0
- ghost_layer-0.1.0.dist-info/entry_points.txt +2 -0
- ghost_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
- ghost_layer-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://github.com/timwal78/ghost-layer)
|
|
45
|
+
[](LICENSE)
|
|
46
|
+
[](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,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
|