feat: per-session AC consumption + corpus gate + exit codes
Phase 1 of the WSL2 jumphost build.
Three execution models:
1. Pre-issued AC: GSAP_AC='...' gsh --exec "cmd"
Caller provides AC. gsh validates (R-22/23/24), executes, posts CR.
For: Bascule, SK plugin, CI/CD.
2. Inline AC request: GSAP_BROKER_URL=... gsh --exec "cmd"
Backward compatible fallback.
3. Ungoverned: gsh --ungoverned --exec "cmd"
No AC, no CR, no corpus check. Dev mode.
AC validation (validate_pre_issued_ac):
R-22: Single-use — filesystem registry at ~/.gsh/consumed/{context_id}
R-23: Corpus match — AC corpus_entry_cid vs GSAP_CORPUS_CID env
R-24: (parameters_cid field parsed, verification at broker)
Expiry check — AC expires_at vs now
Replay detection — consumed context_ids rejected
Corpus directory gate (corpus_check):
/opt/substrate/corpus/{cid}/{command_name}
If binary missing from corpus dir → denied (exit 3)
The live killswitch: remove binary from corpus dir to revoke
Exit codes aligned with DESIGN.md:
0 = success, 1 = exec failure, 2 = auth failure,
3 = governance violation, 125 = gsh internal error
JSON output: new fields ac_mode ("pre-issued"|"inline"|"session"|"ungoverned"), corpus_cid
Tested against live fastapi-gsap broker:
Inline AC: backward compat ✓
Pre-issued AC from broker: validated + CR posted ✓
Expired AC: exit 2 ✓
Replay detection: exit 2 ✓
Ungoverned mode: no governance overhead ✓
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2f9401d3c4
commit
af11a797ee
3 changed files with 538 additions and 79 deletions
150
Cargo.lock
generated
150
Cargo.lock
generated
|
|
@ -242,6 +242,27 @@ dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
|
|
@ -416,6 +437,7 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"dirs",
|
||||||
"hex",
|
"hex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -784,6 +806,15 @@ version = "0.2.184"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
|
|
@ -907,6 +938,12 @@ dependencies = [
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.2"
|
version = "2.3.2"
|
||||||
|
|
@ -968,6 +1005,17 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.12.28"
|
||||||
|
|
@ -1297,6 +1345,26 @@ dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|
@ -1698,13 +1766,22 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-targets",
|
"windows-targets 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1716,34 +1793,67 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows_aarch64_gnullvm",
|
"windows_aarch64_gnullvm 0.52.6",
|
||||||
"windows_aarch64_msvc",
|
"windows_aarch64_msvc 0.52.6",
|
||||||
"windows_i686_gnu",
|
"windows_i686_gnu 0.52.6",
|
||||||
"windows_i686_gnullvm",
|
"windows_i686_gnullvm",
|
||||||
"windows_i686_msvc",
|
"windows_i686_msvc 0.52.6",
|
||||||
"windows_x86_64_gnu",
|
"windows_x86_64_gnu 0.52.6",
|
||||||
"windows_x86_64_gnullvm",
|
"windows_x86_64_gnullvm 0.52.6",
|
||||||
"windows_x86_64_msvc",
|
"windows_x86_64_msvc 0.52.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
@ -1756,24 +1866,48 @@ version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,4 @@ hex = "0.4"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
|
dirs = "5"
|
||||||
|
|
|
||||||
466
src/main.rs
466
src/main.rs
|
|
@ -2,32 +2,45 @@
|
||||||
//!
|
//!
|
||||||
//! GCAP-SPEC-SHELLBOUND-SDK-0001
|
//! GCAP-SPEC-SHELLBOUND-SDK-0001
|
||||||
//!
|
//!
|
||||||
//! Two execution models:
|
//! Three execution models:
|
||||||
//!
|
//!
|
||||||
//! 1. Per-invocation (machine mode):
|
//! 1. Pre-issued AC (target model):
|
||||||
//! gsh --exec "command"
|
//! GSAP_AC='{"context_id":...}' gsh --exec "command"
|
||||||
//! One AC per command. For single governed ops.
|
//! Caller provides AC. gsh validates, executes, posts CR.
|
||||||
|
//! Used by: Bascule, SK plugin, CI/CD.
|
||||||
//!
|
//!
|
||||||
//! 2. Session mode:
|
//! 2. Inline AC request (backward compat):
|
||||||
//! eval "$(gsh session-start)"
|
//! GSAP_BROKER_URL=... gsh --exec "command"
|
||||||
//! gsh --exec "cmd1" # reuses session AC
|
//! gsh requests AC from broker. Fallback when no GSAP_AC.
|
||||||
//! gsh --exec "cmd2"
|
//!
|
||||||
//! gsh session-end
|
//! 3. Ungoverned (dev mode):
|
||||||
//! One AC for the session. N CRs.
|
//! gsh --ungoverned --exec "command"
|
||||||
|
//! No AC, no CR, no corpus check. Just executes.
|
||||||
|
//!
|
||||||
|
//! Session mode works with both pre-issued and inline ACs.
|
||||||
//!
|
//!
|
||||||
//! Environment:
|
//! Environment:
|
||||||
//! GSAP_BROKER_URL required
|
//! GSAP_AC pre-issued AC JSON (preferred)
|
||||||
//! GSAP_AGENT_DID required
|
//! GSAP_BROKER_URL broker URL (for inline AC + CR posting)
|
||||||
//! GSAP_TOKEN optional Bearer auth
|
//! GSAP_AGENT_DID agent DID (for inline AC requests)
|
||||||
//! GSAP_CORPUS_CID optional
|
//! GSAP_TOKEN Bearer auth token
|
||||||
|
//! GSAP_CORPUS_CID corpus this session is authorized for
|
||||||
//! GSAP_SESSION_AC set by session-start
|
//! GSAP_SESSION_AC set by session-start
|
||||||
//! GSAP_SESSION_ID set by session-start
|
//! GSAP_SESSION_ID set by session-start
|
||||||
|
//!
|
||||||
|
//! Exit codes:
|
||||||
|
//! 0 = command succeeded
|
||||||
|
//! 1 = command failed (non-zero exit)
|
||||||
|
//! 2 = authorization failure (AC invalid/expired/missing)
|
||||||
|
//! 3 = governance violation (corpus mismatch, command denied)
|
||||||
|
//! 125 = gsh internal error
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::path::Path;
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -44,6 +57,14 @@ struct Args {
|
||||||
#[arg(long, short = 'e', global = true)]
|
#[arg(long, short = 'e', global = true)]
|
||||||
exec: Option<String>,
|
exec: Option<String>,
|
||||||
|
|
||||||
|
/// Pre-issued AC JSON (alternative to GSAP_AC env)
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
ac: Option<String>,
|
||||||
|
|
||||||
|
/// Run without governance (no AC, no CR, no corpus check)
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
ungoverned: bool,
|
||||||
|
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
broker_url: Option<String>,
|
broker_url: Option<String>,
|
||||||
|
|
||||||
|
|
@ -73,7 +94,38 @@ enum Cmd {
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GSAP types (match broker's expected format) ───────────────
|
// ── Pre-issued AC types ──────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
struct PreIssuedAc {
|
||||||
|
context_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
issued_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
expires_at: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
operation: Option<AcOperation>,
|
||||||
|
#[serde(default)]
|
||||||
|
principal: Option<AcPrincipal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
struct AcOperation {
|
||||||
|
#[serde(default)]
|
||||||
|
corpus_entry_cid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
parameters_cid: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
playbook: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Default)]
|
||||||
|
struct AcPrincipal {
|
||||||
|
#[serde(default)]
|
||||||
|
did: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline AC request types (broker format) ──────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AcRequest {
|
struct AcRequest {
|
||||||
|
|
@ -88,14 +140,16 @@ struct AcRequest {
|
||||||
struct AcResponse {
|
struct AcResponse {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
authorization_context: Option<AcCtx>,
|
authorization_context: Option<AcResponseCtx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AcCtx {
|
struct AcResponseCtx {
|
||||||
context_id: String,
|
context_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── CR types ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct CrRequest {
|
struct CrRequest {
|
||||||
context_id: String,
|
context_id: String,
|
||||||
|
|
@ -124,20 +178,23 @@ struct CrResponse {
|
||||||
chronicle_event_cid: Option<String>,
|
chronicle_event_cid: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Output ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GshOutput {
|
struct GshOutput {
|
||||||
exit_code: i32,
|
exit_code: i32,
|
||||||
stdout: String,
|
stdout: String,
|
||||||
stderr: String,
|
stderr: String,
|
||||||
ac_id: String,
|
ac_id: String,
|
||||||
session_ac: bool,
|
ac_mode: String, // "pre-issued", "inline", "session", "ungoverned"
|
||||||
cr_id: String,
|
cr_id: String,
|
||||||
chronicle_cid: String,
|
chronicle_cid: String,
|
||||||
command_hash: String,
|
command_hash: String,
|
||||||
run_id: String,
|
run_id: String,
|
||||||
|
corpus_cid: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn sha256_hash(data: &[u8]) -> String {
|
fn sha256_hash(data: &[u8]) -> String {
|
||||||
format!("sha256:{}", hex::encode(Sha256::digest(data)))
|
format!("sha256:{}", hex::encode(Sha256::digest(data)))
|
||||||
|
|
@ -146,7 +203,10 @@ fn sha256_hash(data: &[u8]) -> String {
|
||||||
fn build_client(token: &Option<String>) -> Result<Client> {
|
fn build_client(token: &Option<String>) -> Result<Client> {
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
if let Some(tok) = token {
|
if let Some(tok) = token {
|
||||||
headers.insert("Authorization", format!("Bearer {}", tok).parse().context("Invalid token")?);
|
headers.insert(
|
||||||
|
"Authorization",
|
||||||
|
format!("Bearer {}", tok).parse().context("Invalid token")?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Client::builder()
|
Client::builder()
|
||||||
.timeout(Duration::from_secs(30))
|
.timeout(Duration::from_secs(30))
|
||||||
|
|
@ -156,15 +216,124 @@ fn build_client(token: &Option<String>) -> Result<Client> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn broker_url(base: &str, path: &str) -> String {
|
fn broker_url(base: &str, path: &str) -> String {
|
||||||
format!("{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/'))
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
base.trim_end_matches('/'),
|
||||||
|
path.trim_start_matches('/')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str, corpus_cid: &str) -> Result<String> {
|
// ── AC Validation (R-22, R-23, R-24) ────────────────────────
|
||||||
|
|
||||||
|
fn validate_pre_issued_ac(ac_json: &str, corpus_cid: &str) -> Result<PreIssuedAc, i32> {
|
||||||
|
// Parse:
|
||||||
|
let ac: PreIssuedAc = serde_json::from_str(ac_json).map_err(|e| {
|
||||||
|
eprintln!("gsh: AC parse error: {}", e);
|
||||||
|
2 // auth failure
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Must have context_id:
|
||||||
|
if ac.context_id.is_empty() {
|
||||||
|
eprintln!("gsh: AC missing context_id");
|
||||||
|
return Err(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-23: corpus_entry_cid must match (if specified in AC):
|
||||||
|
if let Some(ref op) = ac.operation {
|
||||||
|
if let Some(ref ac_corpus) = op.corpus_entry_cid {
|
||||||
|
if !ac_corpus.is_empty()
|
||||||
|
&& ac_corpus != "sha256:ungoverned"
|
||||||
|
&& corpus_cid != "sha256:ungoverned"
|
||||||
|
&& ac_corpus != corpus_cid
|
||||||
|
{
|
||||||
|
eprintln!(
|
||||||
|
"gsh: corpus mismatch — AC expects {}, running in {}",
|
||||||
|
ac_corpus, corpus_cid
|
||||||
|
);
|
||||||
|
return Err(3); // governance violation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry check:
|
||||||
|
if let Some(ref expires) = ac.expires_at {
|
||||||
|
if let Ok(exp) = chrono::DateTime::parse_from_rfc3339(expires) {
|
||||||
|
if exp < chrono::Utc::now() {
|
||||||
|
eprintln!("gsh: AC expired at {}", expires);
|
||||||
|
return Err(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R-22: single-use check (filesystem registry):
|
||||||
|
let registry_dir = dirs::home_dir()
|
||||||
|
.map(|h| h.join(".gsh").join("consumed"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/.gsh/consumed"));
|
||||||
|
|
||||||
|
let consumed_path = registry_dir.join(&ac.context_id);
|
||||||
|
if consumed_path.exists() {
|
||||||
|
eprintln!("gsh: AC already consumed (replay detected): {}", ac.context_id);
|
||||||
|
return Err(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as consumed:
|
||||||
|
let _ = std::fs::create_dir_all(®istry_dir);
|
||||||
|
let _ = std::fs::write(&consumed_path, "");
|
||||||
|
|
||||||
|
Ok(ac)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Corpus Directory Gate ────────────────────────────────────
|
||||||
|
|
||||||
|
fn corpus_check(corpus_cid: &str, command: &str) -> Result<(), i32> {
|
||||||
|
if corpus_cid == "sha256:ungoverned" {
|
||||||
|
return Ok(()); // ungoverned corpus — no check
|
||||||
|
}
|
||||||
|
|
||||||
|
let corpus_dir = Path::new("/opt/substrate/corpus").join(corpus_cid);
|
||||||
|
if !corpus_dir.exists() {
|
||||||
|
// Corpus directory doesn't exist on this host — allow but warn
|
||||||
|
eprintln!("gsh: corpus directory not found (host may not have corpus mounted)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the command name (first word):
|
||||||
|
let cmd_name = command.split_whitespace().next().unwrap_or(command);
|
||||||
|
let cmd_name = Path::new(cmd_name)
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| cmd_name.to_string());
|
||||||
|
|
||||||
|
let binary_path = corpus_dir.join(&cmd_name);
|
||||||
|
if !binary_path.exists() {
|
||||||
|
eprintln!(
|
||||||
|
"gsh: command '{}' not in corpus {} (killswitch active)",
|
||||||
|
cmd_name, corpus_cid
|
||||||
|
);
|
||||||
|
return Err(3); // governance violation
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline AC Request (fallback) ─────────────────────────────
|
||||||
|
|
||||||
|
fn request_ac_inline(
|
||||||
|
client: &Client,
|
||||||
|
base: &str,
|
||||||
|
operation: &str,
|
||||||
|
command_hash: &str,
|
||||||
|
corpus_cid: &str,
|
||||||
|
) -> Result<String> {
|
||||||
let resp = client
|
let resp = client
|
||||||
.post(broker_url(base, "governance/authorize/"))
|
.post(broker_url(base, "governance/authorize/"))
|
||||||
.json(&AcRequest {
|
.json(&AcRequest {
|
||||||
driver_id: "keycloak".into(),
|
driver_id: "keycloak".into(),
|
||||||
playbook: format!("{}:{}", operation, &command_hash[..20.min(command_hash.len())]),
|
playbook: format!(
|
||||||
|
"{}:{}",
|
||||||
|
operation,
|
||||||
|
&command_hash[..20.min(command_hash.len())]
|
||||||
|
),
|
||||||
corpus_entry_cid: corpus_cid.into(),
|
corpus_entry_cid: corpus_cid.into(),
|
||||||
parameters_cid: command_hash.into(),
|
parameters_cid: command_hash.into(),
|
||||||
accord_template: "shell-exec".into(),
|
accord_template: "shell-exec".into(),
|
||||||
|
|
@ -173,7 +342,11 @@ fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str,
|
||||||
.context("Failed to reach broker")?;
|
.context("Failed to reach broker")?;
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
bail!("AC denied: {} — {}", resp.status(), resp.text().unwrap_or_default());
|
bail!(
|
||||||
|
"AC denied: {} — {}",
|
||||||
|
resp.status(),
|
||||||
|
resp.text().unwrap_or_default()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ac: AcResponse = resp.json().context("Invalid AC response")?;
|
let ac: AcResponse = resp.json().context("Invalid AC response")?;
|
||||||
|
|
@ -185,53 +358,118 @@ fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str,
|
||||||
|
|
||||||
fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) {
|
fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let session_id = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default();
|
||||||
match client
|
match client
|
||||||
.post(broker_url(base, "governance/complete/"))
|
.post(broker_url(base, "governance/complete/"))
|
||||||
.json(&CrRequest {
|
.json(&CrRequest {
|
||||||
context_id: ac_id.into(),
|
context_id: ac_id.into(),
|
||||||
outcome: outcome.into(),
|
outcome: outcome.into(),
|
||||||
completed_at: now,
|
completed_at: now,
|
||||||
chronicle_evidence: CrEvidence { events: vec![], merkle_root: String::new() },
|
chronicle_evidence: CrEvidence {
|
||||||
behavioral_attestation: CrAttestation { status: "unavailable".into() },
|
events: vec![],
|
||||||
ffc: serde_json::json!({"did": "did:web:guildhouse.dev"}),
|
merkle_root: String::new(),
|
||||||
|
},
|
||||||
|
behavioral_attestation: CrAttestation {
|
||||||
|
status: "unavailable".into(),
|
||||||
|
},
|
||||||
|
ffc: serde_json::json!({"did": "did:web:guildhouse.dev", "chronicle_session_id": session_id}),
|
||||||
signature: serde_json::json!({"value": "gsh"}),
|
signature: serde_json::json!({"value": "gsh"}),
|
||||||
})
|
})
|
||||||
.send()
|
.send()
|
||||||
{
|
{
|
||||||
Ok(r) if r.status().is_success() => {
|
Ok(r) if r.status().is_success() => {
|
||||||
let cr: CrResponse = r.json().unwrap_or(CrResponse { receipt_id: None, chronicle_event_cid: None });
|
let cr: CrResponse = r.json().unwrap_or(CrResponse {
|
||||||
(cr.receipt_id.unwrap_or_default(), cr.chronicle_event_cid.unwrap_or_default())
|
receipt_id: None,
|
||||||
|
chronicle_event_cid: None,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
cr.receipt_id.unwrap_or_default(),
|
||||||
|
cr.chronicle_event_cid.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Ok(r) => {
|
||||||
|
eprintln!("gsh: CR failed: {}", r.status());
|
||||||
|
(String::new(), String::new())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("gsh: CR error: {}", e);
|
||||||
|
(String::new(), String::new())
|
||||||
}
|
}
|
||||||
Ok(r) => { eprintln!("gsh: CR failed: {}", r.status()); (String::new(), String::new()) }
|
|
||||||
Err(e) => { eprintln!("gsh: CR error: {}", e); (String::new(), String::new()) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────
|
// ── Main ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
match run(args) {
|
match run(args) {
|
||||||
Ok(code) => process::exit(code),
|
Ok(code) => process::exit(code),
|
||||||
Err(e) => { eprintln!("gsh: {:#}", e); process::exit(125); }
|
Err(e) => {
|
||||||
|
let msg = format!("{:#}", e);
|
||||||
|
eprintln!("gsh: {}", msg);
|
||||||
|
// Map governance exit codes from error message:
|
||||||
|
if msg.contains("exit 2") {
|
||||||
|
process::exit(2); // auth failure
|
||||||
|
} else if msg.contains("exit 3") {
|
||||||
|
process::exit(3); // governance violation
|
||||||
|
} else {
|
||||||
|
process::exit(125); // gsh internal error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: Args) -> Result<i32> {
|
fn run(args: Args) -> Result<i32> {
|
||||||
let base = args.broker_url.clone()
|
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
|
||||||
.context("GSAP_BROKER_URL not set")?;
|
|
||||||
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
||||||
let token = std::env::var("GSAP_TOKEN").ok();
|
let token = std::env::var("GSAP_TOKEN").ok();
|
||||||
|
|
||||||
// Route subcommands:
|
// Ungoverned mode — no governance, just execute:
|
||||||
|
if args.ungoverned {
|
||||||
|
if let Some(ref exec) = args.exec {
|
||||||
|
let output = process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(exec)
|
||||||
|
.output()
|
||||||
|
.context("exec failed")?;
|
||||||
|
let code = output.status.code().unwrap_or(1);
|
||||||
|
if args.json {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&GshOutput {
|
||||||
|
exit_code: code,
|
||||||
|
stdout: String::from_utf8_lossy(&output.stdout).into(),
|
||||||
|
stderr: String::from_utf8_lossy(&output.stderr).into(),
|
||||||
|
ac_id: String::new(),
|
||||||
|
ac_mode: "ungoverned".into(),
|
||||||
|
cr_id: String::new(),
|
||||||
|
chronicle_cid: String::new(),
|
||||||
|
command_hash: sha256_hash(exec.as_bytes()),
|
||||||
|
run_id: Uuid::new_v4().to_string(),
|
||||||
|
corpus_cid: corpus,
|
||||||
|
})?
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
print!("{}", String::from_utf8_lossy(&output.stdout));
|
||||||
|
eprint!("{}", String::from_utf8_lossy(&output.stderr));
|
||||||
|
}
|
||||||
|
return Ok(code);
|
||||||
|
}
|
||||||
|
bail!("--ungoverned requires --exec");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route subcommands (session-start/end/status):
|
||||||
if let Some(cmd) = &args.command {
|
if let Some(cmd) = &args.command {
|
||||||
|
let base = args
|
||||||
|
.broker_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||||
|
.context("GSAP_BROKER_URL not set")?;
|
||||||
let client = build_client(&token)?;
|
let client = build_client(&token)?;
|
||||||
return match cmd {
|
return match cmd {
|
||||||
Cmd::SessionStart { scope } => {
|
Cmd::SessionStart { scope } => {
|
||||||
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
||||||
eprintln!("gsh: starting session (scope: {})", scope);
|
eprintln!("gsh: starting session (scope: {})", scope);
|
||||||
let ac_id = request_ac(&client, &base, scope, &hash, &corpus)?;
|
let ac_id = request_ac_inline(&client, &base, scope, &hash, &corpus)?;
|
||||||
let session_id = Uuid::new_v4().to_string();
|
let session_id = Uuid::new_v4().to_string();
|
||||||
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
||||||
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
||||||
|
|
@ -254,10 +492,17 @@ fn run(args: Args) -> Result<i32> {
|
||||||
match std::env::var("GSAP_SESSION_AC") {
|
match std::env::var("GSAP_SESSION_AC") {
|
||||||
Ok(ac) => {
|
Ok(ac) => {
|
||||||
println!("Session active: {}", &ac[..8.min(ac.len())]);
|
println!("Session active: {}", &ac[..8.min(ac.len())]);
|
||||||
if let Ok(sid) = std::env::var("GSAP_SESSION_ID") { println!("Session ID: {}", sid); }
|
if let Ok(sid) = std::env::var("GSAP_SESSION_ID") {
|
||||||
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") { println!("Scope: {}", scope); }
|
println!("Session ID: {}", sid);
|
||||||
|
}
|
||||||
|
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") {
|
||||||
|
println!("Scope: {}", scope);
|
||||||
|
}
|
||||||
|
println!("Corpus: {}", corpus);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
println!("No session active.\nStart: eval \"$(gsh session-start)\"");
|
||||||
}
|
}
|
||||||
Err(_) => println!("No session active.\nStart: eval \"$(gsh session-start)\""),
|
|
||||||
}
|
}
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
@ -265,65 +510,144 @@ fn run(args: Args) -> Result<i32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --exec mode:
|
// --exec mode:
|
||||||
let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand. Try: gsh --help")?;
|
let exec = args
|
||||||
let client = build_client(&token)?;
|
.exec
|
||||||
|
.as_ref()
|
||||||
|
.context("Provide --exec 'command' or a subcommand. Try: gsh --help")?;
|
||||||
let run_id = Uuid::new_v4().to_string();
|
let run_id = Uuid::new_v4().to_string();
|
||||||
let command_hash = sha256_hash(exec.as_bytes());
|
let command_hash = sha256_hash(exec.as_bytes());
|
||||||
|
|
||||||
// Session mode or per-invocation:
|
// ── Determine AC mode ────────────────────────────────────
|
||||||
let (ac_id, using_session) = match std::env::var("GSAP_SESSION_AC") {
|
//
|
||||||
Ok(session_ac) => (session_ac, true),
|
// Priority:
|
||||||
Err(_) => {
|
// 1. Pre-issued AC (GSAP_AC env or --ac flag)
|
||||||
eprintln!("gsh: requesting AC for '{}'", exec);
|
// 2. Session AC (GSAP_SESSION_AC env)
|
||||||
let id = request_ac(&client, &base, &args.operation, &command_hash, &corpus)?;
|
// 3. Inline request (fallback)
|
||||||
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
|
||||||
(id, false)
|
let pre_issued_ac = args
|
||||||
|
.ac
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("GSAP_AC").ok());
|
||||||
|
|
||||||
|
let (ac_id, ac_mode) = if let Some(ac_json) = pre_issued_ac {
|
||||||
|
// Mode 1: Pre-issued AC — validate and consume
|
||||||
|
let ac = validate_pre_issued_ac(&ac_json, &corpus).map_err(|code| {
|
||||||
|
// Return the exit code as an error
|
||||||
|
anyhow::anyhow!("AC validation failed (exit {})", code)
|
||||||
|
})?;
|
||||||
|
eprintln!(
|
||||||
|
"gsh: pre-issued AC — {}",
|
||||||
|
&ac.context_id[..8.min(ac.context_id.len())]
|
||||||
|
);
|
||||||
|
if let Some(ref p) = ac.principal {
|
||||||
|
if let Some(ref did) = p.did {
|
||||||
|
eprintln!("gsh: principal — {}", did);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
(ac.context_id, "pre-issued".to_string())
|
||||||
|
} else if let Ok(session_ac) = std::env::var("GSAP_SESSION_AC") {
|
||||||
|
// Mode 2: Session AC — reuse
|
||||||
|
(session_ac, "session".to_string())
|
||||||
|
} else {
|
||||||
|
// Mode 3: Inline request — fallback
|
||||||
|
let base = args
|
||||||
|
.broker_url
|
||||||
|
.clone()
|
||||||
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||||
|
.context("No AC provided. Set GSAP_AC, GSAP_SESSION_AC, or GSAP_BROKER_URL")?;
|
||||||
|
let client = build_client(&token)?;
|
||||||
|
eprintln!("gsh: requesting AC for '{}'", exec);
|
||||||
|
let id = request_ac_inline(&client, &base, &args.operation, &command_hash, &corpus)?;
|
||||||
|
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||||
|
(id, "inline".to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Corpus gate ──────────────────────────────────────────
|
||||||
|
if let Err(code) = corpus_check(&corpus, exec) {
|
||||||
|
return Ok(code);
|
||||||
|
}
|
||||||
|
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
eprintln!("gsh: dry-run — skipping exec");
|
eprintln!("gsh: dry-run — skipping exec");
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
println!(
|
||||||
exit_code: 0, stdout: String::new(), stderr: String::new(),
|
"{}",
|
||||||
ac_id, session_ac: using_session, cr_id: String::new(),
|
serde_json::to_string_pretty(&GshOutput {
|
||||||
chronicle_cid: String::new(), command_hash, run_id,
|
exit_code: 0,
|
||||||
})?);
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
ac_id,
|
||||||
|
ac_mode,
|
||||||
|
cr_id: String::new(),
|
||||||
|
chronicle_cid: String::new(),
|
||||||
|
command_hash,
|
||||||
|
run_id,
|
||||||
|
corpus_cid: corpus,
|
||||||
|
})?
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute:
|
// ── Execute ──────────────────────────────────────────────
|
||||||
let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
|
let output = process::Command::new("sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(exec)
|
||||||
|
.output()
|
||||||
|
.context("exec failed")?;
|
||||||
|
|
||||||
let exit_code = output.status.code().unwrap_or(1);
|
let exit_code = output.status.code().unwrap_or(1);
|
||||||
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
// Post CR (in session mode, this may fail on "already consumed" — that's a broker gap, not gsh's fault):
|
// ── Post CR ──────────────────────────────────────────────
|
||||||
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
let outcome = if exit_code == 0 {
|
||||||
let (cr_id, chronicle_cid) = if !using_session {
|
"completed"
|
||||||
post_cr(&client, &base, &ac_id, outcome)
|
|
||||||
} else {
|
} else {
|
||||||
// Session mode: post CR but accept failure gracefully
|
"failed"
|
||||||
// (broker currently marks AC consumed after first CR)
|
};
|
||||||
|
|
||||||
|
let base = args
|
||||||
|
.broker_url
|
||||||
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok());
|
||||||
|
|
||||||
|
let (cr_id, chronicle_cid) = if let Some(base) = base {
|
||||||
|
let client = build_client(&token)?;
|
||||||
let (id, cid) = post_cr(&client, &base, &ac_id, outcome);
|
let (id, cid) = post_cr(&client, &base, &ac_id, outcome);
|
||||||
if id.is_empty() && cid.is_empty() {
|
if ac_mode == "session" && id.is_empty() && cid.is_empty() {
|
||||||
eprintln!("gsh: session CR not recorded (broker session support pending)");
|
eprintln!("gsh: session CR not recorded (broker session support pending)");
|
||||||
}
|
}
|
||||||
(id, cid)
|
(id, cid)
|
||||||
|
} else {
|
||||||
|
eprintln!("gsh: no GSAP_BROKER_URL — CR not posted");
|
||||||
|
(String::new(), String::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Output:
|
// ── Output ───────────────────────────────────────────────
|
||||||
if args.json {
|
if args.json {
|
||||||
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
println!(
|
||||||
exit_code, stdout: stdout_str, stderr: stderr_str,
|
"{}",
|
||||||
ac_id, session_ac: using_session, cr_id, chronicle_cid, command_hash, run_id,
|
serde_json::to_string_pretty(&GshOutput {
|
||||||
})?);
|
exit_code,
|
||||||
|
stdout: stdout_str,
|
||||||
|
stderr: stderr_str,
|
||||||
|
ac_id,
|
||||||
|
ac_mode,
|
||||||
|
cr_id,
|
||||||
|
chronicle_cid,
|
||||||
|
command_hash,
|
||||||
|
run_id,
|
||||||
|
corpus_cid: corpus,
|
||||||
|
})?
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
print!("{}", stdout_str);
|
print!("{}", stdout_str);
|
||||||
eprint!("{}", stderr_str);
|
eprint!("{}", stderr_str);
|
||||||
if !chronicle_cid.is_empty() {
|
if !chronicle_cid.is_empty() {
|
||||||
eprintln!("gsh: CID {}", &chronicle_cid[..40.min(chronicle_cid.len())]);
|
eprintln!(
|
||||||
|
"gsh: CID {}",
|
||||||
|
&chronicle_cid[..40.min(chronicle_cid.len())]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue