bastion agent 0.1.14
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
"""CLI: python3 -m bastion_agent {validate,show,payload,walk} <manifest.yaml> [...]"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import doctor as _doctor
|
||||
from .catalog import CatalogError, catalog
|
||||
from .embed import EmbedError, chunk, embed, search
|
||||
from .extract import extract
|
||||
from .manifest import ManifestError, load_manifest, signing_digest, signing_payload
|
||||
from .run import GateError, RunError, default_out, sign_manifest_file, sweep, write_signature
|
||||
from .sanitize import SanitizeError, sanitize
|
||||
from .seal import SealError, seal, unseal
|
||||
from .serve import ServeError, serve
|
||||
from .walk import walk
|
||||
|
||||
|
||||
def _show(m, path):
|
||||
print(f"{path}: {m.id} (owner {m.owner_host})")
|
||||
print(f" roots: {', '.join(m.scope.roots)}")
|
||||
if m.scope.exclude:
|
||||
print(f" exclude: {', '.join(m.scope.exclude)}")
|
||||
print(f" mask: {' '.join(m.mask)}")
|
||||
print(f" stages: {' -> '.join(m.stages)}")
|
||||
if m.sanitize:
|
||||
print(f" sanitize: {m.sanitize.mode} (scope {m.sanitize.scope}"
|
||||
f"{', verify' if m.sanitize.verify else ''})")
|
||||
if m.embed:
|
||||
print(f" embed: {m.embed.model} {m.embed.dim}d, chunk "
|
||||
f"{m.embed.chunk_size_tokens}/{m.embed.chunk_overlap_tokens}")
|
||||
if m.summarize:
|
||||
print(f" summarize: {m.summarize.model} per {', '.join(m.summarize.per)}")
|
||||
print(f" sweep: {m.sweep}")
|
||||
print(f" sink: catalog={m.sink.catalog} vectors={m.sink.vectors} "
|
||||
f"plaintext={m.sink.plaintext}"
|
||||
f"{' seal_to=' + ','.join(m.sink.seal_to) if m.sink.seal_to else ''}")
|
||||
if m.grants:
|
||||
for g in m.grants:
|
||||
print(f" grant: {g.host} may {', '.join(g.ops)} until {g.until.isoformat()}")
|
||||
else:
|
||||
print(" grants: none")
|
||||
print(f" signed: {'yes (' + m.signature.alg + ')' if m.signed else 'NO — runtime will refuse'}")
|
||||
print(f" digest: sha256:{signing_digest(m.raw)}")
|
||||
|
||||
|
||||
def _version():
|
||||
# pkgutil reads from a zipapp as well as a checkout; a plain open() cannot
|
||||
import pkgutil
|
||||
try:
|
||||
data = pkgutil.get_data(__package__ or "bastion_agent", "VERSION")
|
||||
return data.decode("utf-8").strip() if data else "0.0.0"
|
||||
except (OSError, ValueError):
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(prog="bastion-agent")
|
||||
parser.add_argument("--version", action="version", version="bastion-agent %s" % _version())
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
p_doc = sub.add_parser("doctor", help="preflight: python, tools, endpoints, environment")
|
||||
p_doc.add_argument("--json", action="store_true")
|
||||
p_val = sub.add_parser("validate", help="validate manifest(s), exit 1 on any error")
|
||||
p_val.add_argument("--require-signed", action="store_true",
|
||||
help="also fail when the signature block is absent")
|
||||
p_val.add_argument("paths", nargs="+")
|
||||
p_show = sub.add_parser("show", help="print a manifest's effective plan")
|
||||
p_show.add_argument("paths", nargs="+")
|
||||
p_pay = sub.add_parser("payload", help="emit the canonical signing payload (bytes) to stdout")
|
||||
p_pay.add_argument("path")
|
||||
p_walk = sub.add_parser("walk", help="run the walk stage: filesystem + metadata map")
|
||||
p_walk.add_argument("path")
|
||||
p_walk.add_argument("--out", help="output directory (default <root>/.agent, or build/freeagent/<id> for several roots)")
|
||||
p_walk.add_argument("--no-hash", action="store_true",
|
||||
help="metadata only; skip SHA-256 (fast survey)")
|
||||
p_walk.add_argument("--require-signed", action="store_true",
|
||||
help="refuse an unsigned manifest, as the runtime will")
|
||||
p_ext = sub.add_parser("extract", help="run the extract stage: text out of walked files")
|
||||
p_ext.add_argument("path")
|
||||
p_ext.add_argument("--out", help="output directory (default <root>/.agent, or build/freeagent/<id> for several roots)")
|
||||
p_ext.add_argument("--no-external", action="store_true",
|
||||
help="built-in extractors only; ignore pdftotext/tesseract on PATH")
|
||||
p_ext.add_argument("--require-signed", action="store_true")
|
||||
p_san = sub.add_parser("sanitize", help="run the sanitize stage: PII out of the extracted text")
|
||||
p_san.add_argument("path")
|
||||
p_san.add_argument("--out", help="output directory (default <root>/.agent, or build/freeagent/<id> for several roots)")
|
||||
p_san.add_argument("--require-signed", action="store_true")
|
||||
for name, helptext in (("chunk", "run the chunk stage: sanitized text -> overlapping windows"),
|
||||
("embed", "run the embed stage: chunks -> local vectors.db")):
|
||||
p = sub.add_parser(name, help=helptext)
|
||||
p.add_argument("path")
|
||||
p.add_argument("--out")
|
||||
p.add_argument("--require-signed", action="store_true")
|
||||
p_srch = sub.add_parser("search", help="query the local vectors (proves the lane end to end)")
|
||||
p_srch.add_argument("path")
|
||||
p_srch.add_argument("query")
|
||||
p_srch.add_argument("--out")
|
||||
p_srch.add_argument("-k", type=int, default=5)
|
||||
p_cat = sub.add_parser("catalog", help="run the catalog stage: file + directory cards")
|
||||
p_cat.add_argument("path")
|
||||
p_cat.add_argument("--out")
|
||||
p_cat.add_argument("--require-signed", action="store_true")
|
||||
p_seal = sub.add_parser("seal", help="run the seal stage: catalog -> age envelope for the owner")
|
||||
p_seal.add_argument("path")
|
||||
p_seal.add_argument("--out")
|
||||
p_seal.add_argument("--require-signed", action="store_true")
|
||||
p_unseal = sub.add_parser("unseal", help="owner-side: open a sealed bundle with an age identity")
|
||||
p_unseal.add_argument("sealed")
|
||||
p_unseal.add_argument("--identity", required=True, help="age identity file (age-keygen output)")
|
||||
p_unseal.add_argument("--dest", required=True)
|
||||
p_sw = sub.add_parser("sweep", help="the runtime: gate the manifest, run its stages on its cadence")
|
||||
p_sw.add_argument("path")
|
||||
p_sw.add_argument("--out", help="output directory (default <root>/.agent, or build/freeagent/<id> "
|
||||
"for several roots; the same folder serve uses)")
|
||||
p_sw.add_argument("--once", action="store_true", help="one run, then exit (any cadence)")
|
||||
p_sw.add_argument("--allow-unsigned", action="store_true",
|
||||
help="DEVELOPMENT ONLY: run an unsigned or unverifiable manifest; recorded in run.jsonl")
|
||||
p_sign = sub.add_parser("sign", help="write the signature block into a manifest (host-side helper)")
|
||||
p_sign.add_argument("path")
|
||||
p_sign_by = p_sign.add_mutually_exclusive_group(required=True)
|
||||
p_sign_by.add_argument("--key", help="PEM private key (signs with openssl)")
|
||||
p_sign_by.add_argument("--value", help="a base64 DER signature over `payload`, made "
|
||||
"elsewhere (the bastion signs with its own key)")
|
||||
p_sign.add_argument("--cert", required=True, help="cert path to record in the manifest")
|
||||
p_srv = sub.add_parser("serve", help="the query endpoint: search/read_range over mTLS for owner + grantees")
|
||||
p_srv.add_argument("path")
|
||||
p_srv.add_argument("--out")
|
||||
p_srv.add_argument("--bind", help="host:port or [v6]:port (default FREEAGENT_BIND or [::]:8470)")
|
||||
p_srv.add_argument("--allow-unsigned", action="store_true",
|
||||
help="DEVELOPMENT ONLY: serve an unsigned/unverifiable manifest")
|
||||
p_srv.add_argument("--port-file", help="write the bound port here (for a supervisor binding port 0)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "doctor":
|
||||
return _doctor.main(["--json"] if args.json else [])
|
||||
|
||||
if args.cmd == "serve":
|
||||
try:
|
||||
m = load_manifest(args.path)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
serve(args.path, out, allow_unsigned=args.allow_unsigned, bind=args.bind,
|
||||
port_file=args.port_file)
|
||||
except ServeError as exc:
|
||||
print("serve: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
if args.cmd == "sign":
|
||||
try:
|
||||
if args.value:
|
||||
import base64
|
||||
try:
|
||||
der = base64.b64decode(args.value, validate=True)
|
||||
except Exception:
|
||||
print("sign: --value is not base64", file=sys.stderr)
|
||||
return 1
|
||||
write_signature(args.path, der, args.cert)
|
||||
else:
|
||||
sign_manifest_file(args.path, args.key, args.cert)
|
||||
except (GateError, ManifestError) as exc:
|
||||
print("sign: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
print("%s: signed (cert %s)" % (args.path, args.cert))
|
||||
return 0
|
||||
|
||||
if args.cmd == "sweep":
|
||||
try:
|
||||
last = sweep(args.path, args.out, allow_unsigned=args.allow_unsigned, once=args.once)
|
||||
except RunError as exc:
|
||||
print("sweep: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
return 0 if (last and last.get("ok")) else 1
|
||||
|
||||
if args.cmd == "unseal":
|
||||
try:
|
||||
inv = unseal(args.sealed, args.identity, args.dest)
|
||||
except SealError as exc:
|
||||
print("unseal: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
print("%s: %d members verified -> %s (manifest %s, sealed %s)"
|
||||
% (args.sealed, len(inv["members"]), args.dest, inv["manifest_id"], inv["sealed_at"]))
|
||||
return 0
|
||||
|
||||
if args.cmd == "seal":
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=args.require_signed)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
s = seal(m, out)
|
||||
except SealError as exc:
|
||||
print("seal: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
print("%s: sealed %d members (%d bytes -> %d) to %d recipient(s)%s -> %s"
|
||||
% (m.id, len(s["members"]), s["bytes_plain"], s["bytes_sealed"],
|
||||
len(s["recipients"]), " + signature" if s["signature"] else "",
|
||||
os.path.join(out, s["file"])))
|
||||
return 0
|
||||
|
||||
if args.cmd == "catalog":
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=args.require_signed)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
if "catalog" not in m.stages:
|
||||
print("%s: 'catalog' is not in this manifest's stages" % args.path, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
s = catalog(m, out)
|
||||
except (FileNotFoundError, CatalogError) as exc:
|
||||
print("catalog: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
c = s["counts"]
|
||||
print("%s: %d file cards (%d with text), %d directory cards, %d names masked; "
|
||||
"summaries %d new, %d reused [%s]; catalog %d bytes -> %s"
|
||||
% (m.id, c["files"], c["with_text"], c["dirs"], c["names_masked"],
|
||||
c["summaries"], c["summaries_reused"], s["summarizer"] or "none",
|
||||
s["bytes_catalog"], out))
|
||||
if s["stopped"]:
|
||||
print("catalog: summarizer STOPPED: %s" % s["stopped"], file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.cmd in ("chunk", "embed", "search"):
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=getattr(args, "require_signed", False))
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
stage = "embed" if args.cmd == "search" else args.cmd
|
||||
if stage not in m.stages:
|
||||
print("%s: %r is not in this manifest's stages" % (args.path, stage), file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
if args.cmd == "chunk":
|
||||
s = chunk(m, out)
|
||||
c = s["counts"]
|
||||
print("%s: %d docs -> %d chunks (%d/%d tokens), %d skipped -> %s"
|
||||
% (m.id, c["docs"], c["chunks"], s["size_tokens"], s["overlap_tokens"],
|
||||
c["skipped"], out))
|
||||
return 0
|
||||
if args.cmd == "embed":
|
||||
s = embed(m, out)
|
||||
c = s["counts"]
|
||||
print("%s: %d chunks in %d docs -> %d embedded, %d reused [%s, %dd]; "
|
||||
"%d stale vectors kept; store %s"
|
||||
% (m.id, c["chunks"], c["docs"], c["embedded"], c["reused"], s["endpoint"],
|
||||
s["dim"], s["stale_vectors"], s["store"]))
|
||||
return 0
|
||||
hits = search(m, out, args.query, k=args.k)
|
||||
for h in hits:
|
||||
snippet = h["text"].replace("\n", " ")[:160]
|
||||
print("%.4f %s#%d %s" % (h["score"], h["doc"][:12], h["idx"], snippet))
|
||||
if not hits:
|
||||
print("no vectors for model %s" % m.embed.model, file=sys.stderr)
|
||||
return 0
|
||||
except (FileNotFoundError, EmbedError) as exc:
|
||||
print("%s: %s" % (args.cmd, exc), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.cmd == "sanitize":
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=args.require_signed)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
if "sanitize" not in m.stages:
|
||||
print("%s: 'sanitize' is not in this manifest's stages" % args.path, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
s = sanitize(m, out)
|
||||
except (FileNotFoundError, SanitizeError) as exc:
|
||||
print("sanitize: %s" % exc, file=sys.stderr)
|
||||
return 1
|
||||
c = s["counts"]
|
||||
types = ", ".join("%s=%d" % kv for kv in sorted(s["types"].items())) or "none"
|
||||
print("%s: %d files -> %d sanitized, %d reused, %d skipped, %d errors, %d not run "
|
||||
"[%s, scope %s]; tokens: %s -> %s"
|
||||
% (m.id, c["files"], c["ok"], c["reused"], c["skipped"], c["error"], c["not_run"],
|
||||
s["sterilizer"], s["scope"], types, out))
|
||||
if s["stopped"]:
|
||||
print("sanitize STOPPED (fail closed): %s" % s["stopped"], file=sys.stderr)
|
||||
return 1
|
||||
return 2 if c["error"] else 0
|
||||
|
||||
if args.cmd == "extract":
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=args.require_signed)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
if "extract" not in m.stages:
|
||||
print("%s: 'extract' is not in this manifest's stages" % args.path, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
try:
|
||||
s = extract(m, out, use_external=not args.no_external)
|
||||
except FileNotFoundError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
c = s["counts"]
|
||||
tools = ", ".join(k for k, v in s["tools"].items() if v) or "none"
|
||||
print("%s: %d files -> %d extracted (%d chars), %d reused, %d empty, %d unsupported, "
|
||||
"%d skipped, %d errors; tools: %s -> %s"
|
||||
% (m.id, c["files"], c["ok"], s["chars"], c["reused"], c["empty"],
|
||||
c["unsupported"], c["skipped"], c["error"], tools, out))
|
||||
return 2 if c["error"] else 0
|
||||
|
||||
if args.cmd == "walk":
|
||||
try:
|
||||
m = load_manifest(args.path, require_signed=args.require_signed)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
out = args.out or default_out(m)
|
||||
s = walk(m, out, do_hash=not args.no_hash)
|
||||
c = s["counts"]
|
||||
print("%s: walked %d entries, %d files (%d bytes), %d dirs, %d links; "
|
||||
"%d hashed, %d reused, %d too large, %d excluded, %d errors -> %s"
|
||||
% (m.id, s["entries"], c["files"], s["bytes"], c["dirs"], c["symlinks"],
|
||||
c["hashed"], c["hash_reused"], c["too_large"], c["excluded"],
|
||||
c["errors"], out))
|
||||
for r in s["roots"]:
|
||||
if r.get("error"):
|
||||
print(" root %d %s: %s" % (r["index"], r["path"], r["error"]), file=sys.stderr)
|
||||
return 2 if c["errors"] else 0
|
||||
|
||||
if args.cmd == "payload":
|
||||
try:
|
||||
m = load_manifest(args.path)
|
||||
except ManifestError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 1
|
||||
sys.stdout.buffer.write(signing_payload(m.raw))
|
||||
return 0
|
||||
|
||||
rc = 0
|
||||
for path in args.paths:
|
||||
try:
|
||||
m = load_manifest(path, require_signed=getattr(args, "require_signed", False))
|
||||
except ManifestError as exc:
|
||||
print(f"{path}:", file=sys.stderr)
|
||||
print(exc, file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
if args.cmd == "validate":
|
||||
print(f"{path}: OK — {m.id}, {len(m.stages)} stages, {len(m.grants)} grants, "
|
||||
f"{'signed' if m.signed else 'unsigned'}")
|
||||
else:
|
||||
_show(m, path)
|
||||
return rc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user