mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-20 06:00:06 +08:00
* docs(rfc): add agent-driven policy management * docs(rfc): switch policy MVP to local API * docs(rfc): clarify policy advisor skill and local logs * feat(sandbox): add agent-driven policy proposal loop * test(examples): add codex policy dogfood loop * refactor(examples): make policy demo agent-agnostic * refactor(examples): colocate policy validation harness * docs(examples): add policy demo env sample * docs(examples): use placeholder env example * feat(sandbox): wire policy.local denials to OCSF JSONL log Wires GET /v1/denials?last=N on the sandbox-local policy advisor API to read recent OCSF JSONL events from /var/log/openshell-ocsf.YYYY-MM-DD.log, filter to network/L7 denials (action_id=2, class_uid 4001/4002), and return a compact summary newest-first. Default limit is 10, capped at 100. Ran inside spawn_blocking so file I/O does not block the policy.local handler. Other cleanup: - POST /v1/proposals now uses the typed grpc_client wrapper instead of raw_client, so accepted/rejected counts surface to the agent uniformly. Wrapper return type extended to the response struct. - Drop the 'add_rule' snake_case alias in the proposal JSON; canonical form is camelCase 'addRule', matching the PolicyMergeOperation convention used elsewhere. - skills/policy_advisor.md updated to match: documents the now-real /v1/denials?last=10 endpoint and uses 'addRule' consistently. - skills.rs test asserts on the canonical 'addRule' phrase rather than the removed 'PolicyMergeOperation' substring. * feat(cli): show L7 protocol/method/path in rule get output format_endpoint() previously rendered only host:port, dropping protocol, access, and the L7 rules array. That made openshell rule get text output unable to distinguish a broad L4 grant from a method/path-scoped L7 REST rule -- exactly the distinction a developer needs at approval time. New rendering tags each endpoint with its enforcement layer and surfaces allow/deny rules: bare L4: api.example:443 [L4] L7 read-only: api.example:443 [L7 rest, access=read-only] L7 method/path: api.example:443 [L7 rest, allow PUT /v1/foo/bar] Pure display change: no proto, gateway, or behavior changes. Unit test covers all three rendering cases with synthetic fixtures. * refactor(examples): rewrite policy demo as Codex-default loop Re-shape examples/agent-driven-policy-management/ to be a single, clean end-to-end demonstration of the agent-driven policy loop. A Codex agent inside an OpenShell sandbox attempts a GitHub Contents API write, hits a structured 403 from the L7 proxy, reads the policy_advisor skill, drafts a narrow addRule proposal via http://policy.local/v1/proposals, the host auto-approves, the sandbox hot-reloads policy, and the agent's retry succeeds. Whole loop runs in roughly two minutes. Demo cleanup: - Drop .env file ceremony. Defaults resolve from gh: owner via 'gh api user --jq .login', repo defaults to 'openshell-policy-demo', token from gh auth token / GITHUB_TOKEN / GH_TOKEN. With gh auth login and codex login already done, 'bash demo.sh' Just Works. - Codex-specific. Bootstraps ~/.codex/auth.json from credentials injected by the OpenShell provider, runs codex exec --sandbox danger-full-access (OpenShell is the actual security boundary; bwrap nesting cannot create user namespaces inside the sandbox container). - Tighter narrative output: a single 'Preflight' step, a run summary banner before launch, an inline narration of what's happening inside the sandbox while we poll for the proposal (including the literal structured 403 body the agent acts on), and an OCSF trace at the end filtered to the three events that tell the story (DENY, RELOAD, ALLOW). - Replace Python heredoc templating with sed; uploads use the single-flag pattern (--upload "${PAYLOAD_DIR}:/sandbox") with files referenced at the basename-prefixed path that #952 / #1028 established. - README documents the trust model honestly: structured rule is the contract, agent rationale is a hint, prover validation badge in progress per RFC 0001. Move the deterministic no-LLM regression harness out of examples/ into e2e/policy-advisor/ -- it was a parallel demo, not an example. Same loop without the LLM, useful for iterating on the proxy and policy.local API. * style(sandbox,cli): apply rustfmt Whitespace-only fixups caught by mise run pre-commit. No functional change. * perf(examples): cap Codex reasoning at 'low' in policy demo The demo task is mechanical (one HTTP request, parse a structured 403, post a JSON proposal, retry). Codex's default high-effort reasoning roughly doubles the demo's wall time without improving outcomes; running at 'low' lands the same minimal L7 grant in roughly half the time. Override with DEMO_CODEX_REASONING=medium (or higher) to compare runs. * fix(sandbox): harden policy.local denials endpoint Three changes addressing review feedback before merging the agent-driven policy management MVP: - Distinguish "OCSF JSONL enabled, no denials" from "OCSF JSONL disabled, nothing to read." The endpoint now returns a `log_available` flag and an explanatory `note` when the log file is missing, so the in-sandbox agent can give the developer an accurate hint instead of a misleading empty list. - Stop echoing the OCSF `message` field in the per-denial summary. The proxy's denial messages can include the request path with query string (e.g., `?access_token=...`); the structured `host`/`port`/`method`/ `path`/`binary` fields carry everything the agent needs to draft a proposal, and `path` is sourced from `http_request.url.path` which already excludes the query string. - Cap `read_request_body` at a 15s timeout. Bounds slowloris-style stalls from a misbehaving in-sandbox process. The proxy listener only accepts loopback connections so practical impact is small, but this is cheap defense-in-depth. New tests cover the missing-log signal and the message-redaction guarantee. * fix(examples): redact tokens in agent log tail and validate DEMO_FILE_DIR Two small hardening passes on the policy management demo: - `fail()` now pipes the agent log tail through a redactor that masks the GitHub token and Codex credential triple before printing. Codex itself is well-behaved about not echoing the token, but a misbehaving tool call could leak it; this is a final safety net before the log hits the developer's terminal (and any clipboard or chat history that follows). - `validate_env` now regex-checks DEMO_FILE_DIR with the same allow-list the other path-shaped variables use. The value is interpolated through sed with `|` as the delimiter when rendering the agent task; rejecting unsupported characters keeps the templating predictable and stops a user-supplied value from breaking out into a shell context. * refactor(sandbox): centralize policy.local routes and skill path Addresses review feedback that the deny body's `next_steps` array and the route table could drift apart. The route paths and skill location now live as `pub const`s in `policy_local.rs` and feed both: - the dispatcher in `route_request` that matches against them - a new `agent_next_steps()` helper that builds the JSON the L7 deny body embeds `l7/rest.rs::deny_response_body` calls `policy_local::agent_next_steps()` instead of inlining the array, so adding or renaming a route is a one-line change in `policy_local.rs` and the agent contract follows automatically. * feat(sandbox): switch /v1/denials to shorthand log pass-through Previously /v1/denials parsed `/var/log/openshell-ocsf.*.log` (OCSF JSONL) and returned structured per-event objects. JSONL is opt-in via `ocsf_json_enabled`, so the endpoint returned an empty list with a "log not enabled" hint by default — agents had to navigate a setup step before the inspect-recent-denials guidance was useful. Switch to reading the shorthand log at `/var/log/openshell.*.log`, which is always-on and the same human-readable format `openshell logs` displays. The endpoint now returns raw shorthand lines (newest first) — the agent reads them directly, no field parsing. Tradeoffs: - Removes the JSONL-on-by-default debate: shorthand is already on, no defaults change. - Updating shorthand is a single-file change in this repo; no schema rev needed when we want to add fields. Implementation: - `read_recent_denial_lines` walks shorthand log files newest-first, filters lines with ` OCSF ` AND ` DENIED ` (the OCSF action label, uppercase, space-bounded). - `collect_shorthand_log_files` matches `openshell.<date>.log`; the trailing dot in `SHORTHAND_LOG_PREFIX = "openshell."` excludes `openshell-ocsf.<date>.log` so JSONL-on doesn't bleed into responses. - 4096-byte cap per surfaced line as defense against pathological inputs. - Skill doc updated to reflect that `/v1/denials` returns raw shorthand lines, not structured fields. Defense-in-depth on query-string secrets: - `redact_query_strings` strips `?<query>` to `?[redacted]` from each surfaced line. The L7 relay path emits OCSF events using `redacted_target` (secret-placeholder redaction), but the FORWARD deny path in `proxy.rs` populates `OcsfUrl::new("http", host, path, port)` and `.message(...)` with the raw request path — query string included. Stripping queries at the consumer guards `/v1/denials` regardless of whether the upstream emit sites are tightened. The on-disk log is not rewritten by this change; that is a separate hardening task tracked for the FORWARD path emit sites in proxy.rs. - `truncate_at_char_boundary` is UTF-8 safe; redaction runs before truncation so a cut cannot slice mid-secret. Tests: - `recent_denials_returns_newest_first_from_shorthand_lines` covers the happy path with mixed allowed/denied/non-OCSF lines. - `recent_denials_skips_jsonl_log_files` confirms JSONL files don't surface even if present. - `recent_denials_truncates_pathological_lines` covers the cap. - `is_ocsf_denial_line_filters_correctly` covers the line-level filter. - `redact_query_strings_removes_query_from_url_token` and `redact_query_strings_removes_query_in_reason_tag` cover the redaction in both URL token and `[reason:...]` contexts. - `truncate_at_char_boundary_does_not_panic_on_multibyte` covers the UTF-8 safety. * chore(sandbox): align proto inits with main's L7 GraphQL additions Post-rebase fixups after #1083 (GraphQL L7 inspection) landed on main and introduced new fields on the proto types this branch constructs: - `crates/openshell-sandbox/src/l7/relay.rs`: two `deny_with_redacted_target` call sites (REST and GraphQL relay deny paths) now pass the `DenyResponseContext` argument that `rest::send_deny_response` expects. Both sites pass `host`, `port`, and `binary` from the existing `L7EvalContext`, matching the pattern used at the primary deny site. - `crates/openshell-sandbox/src/policy_local.rs`: `L7Allow`, `L7DenyRule`, and `NetworkEndpoint` proto initializers now populate the new GraphQL and path-scoping fields with empty defaults. Agent-authored proposals via `policy.local` target REST/SQL/L4 today; GraphQL operation matching is set on the gateway side or via direct YAML, so empty defaults are correct here. No behavior change. `cargo test -p openshell-sandbox --lib` (650 tests) and `cargo clippy -p openshell-sandbox --lib --tests -- -D warnings` clean. * feat(sandbox): gate agent policy proposals behind opt-in feature flag The agent-driven policy proposal surface delivered by this PR (skill install, `policy.local` API, `next_steps` array on L7 deny bodies) is now opt-in via the new `agent_policy_proposals_enabled` setting. Default false. Same shape as `providers_v2_enabled`: registered in `openshell-core::settings`, sandbox-level, hot-toggleable via the existing settings poll loop. Why: the surface is a novel agent-controlled mutation point in every sandbox. The per-proposal developer approval gate is a correctness control, but it doesn't address "should this sandbox have an agent-authoring API at all" — compliance teams may want that question closed. The flag is the second gate. Implementation: - New registry entry + `AGENT_POLICY_PROPOSALS_ENABLED_KEY` constant in `openshell-core::settings`. - `lib.rs`: process-wide `OnceLock<Arc<AtomicBool>>` mirroring the `OCSF_CTX` pattern. `agent_proposals_enabled()` is the single read point. - Initial settings fetch added to `run_sandbox` so skill install honors the flag at startup (not just on the poll loop's first tick). - Skill install in `run_sandbox` is gated on the flag. - `policy_local::route_request` returns `404 feature_disabled` for all routes when the flag is off — including the otherwise-public `current_policy` and `denials` routes. When the surface is off it's off entirely. - `policy_local::agent_next_steps` returns an empty array when the flag is off so deny bodies don't advertise routes that 404. - Poll loop updates the atomic on each tick, lazily installs the skill on a false→true transition (no claw-back on true→false; stale skill on disk is harmless because route + next_steps gate on the live atom). Tests: - Shared `test_helpers::ProposalsFlagGuard` mutex+atomic guard for the process-wide flag, used across `policy_local::tests` and `l7::rest::tests`. - New: `agent_next_steps_returns_empty_when_flag_off`, `agent_next_steps_returns_full_array_when_flag_on`, `route_request_returns_feature_disabled_when_flag_off`. - Updated existing tests that exercise the deny body or the route dispatcher to set the flag on first. - Full sandbox lib test suite: 653 pass, clippy clean. Demo and e2e: - `examples/agent-driven-policy-management/demo.sh` and `e2e/policy-advisor/test.sh` now snapshot the prior global value of the setting, set it to true before sandbox creation (so the supervisor's initial poll picks it up), and restore on exit (delete if previously unset, otherwise write the prior value back). Docs: - RFC 0001 MVP-implementation note documents the flag, default, and intended soft-launch posture. * test(policy-advisor): require proposal opt-in for e2e * refactor(sandbox): group policy poll loop state * test(e2e): isolate Kubernetes user namespace test --------- Co-authored-by: John Myers <9696606+johntmyers@users.noreply.github.com>
411 lines
14 KiB
Bash
Executable File
411 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
POLICY_TEMPLATE="${SCRIPT_DIR}/policy.template.yaml"
|
|
RUNNER_SOURCE="${SCRIPT_DIR}/sandbox-runner.sh"
|
|
|
|
if [[ -z "${OPENSHELL_BIN:-}" ]]; then
|
|
if [[ -x "${REPO_ROOT}/target/debug/openshell" ]]; then
|
|
OPENSHELL_BIN="${REPO_ROOT}/target/debug/openshell"
|
|
else
|
|
OPENSHELL_BIN="openshell"
|
|
fi
|
|
fi
|
|
|
|
DEMO_BRANCH="${DEMO_BRANCH:-main}"
|
|
DEMO_RUN_ID="${DEMO_RUN_ID:-$(date +%Y%m%d-%H%M%S)}"
|
|
DEMO_FILE_DIR="${DEMO_FILE_DIR:-openshell-policy-advisor-validation}"
|
|
DEMO_FILE_PATH="${DEMO_FILE_PATH:-${DEMO_FILE_DIR}/${DEMO_RUN_ID}.md}"
|
|
DEMO_SANDBOX_NAME="${DEMO_SANDBOX_NAME:-policy-agent-validation-${DEMO_RUN_ID}}"
|
|
DEMO_GITHUB_PROVIDER_NAME="${DEMO_GITHUB_PROVIDER_NAME:-github-policy-validation-${DEMO_RUN_ID}}"
|
|
DEMO_KEEP_SANDBOX="${DEMO_KEEP_SANDBOX:-0}"
|
|
DEMO_RETRY_ATTEMPTS="${DEMO_RETRY_ATTEMPTS:-30}"
|
|
DEMO_RETRY_SLEEP="${DEMO_RETRY_SLEEP:-2}"
|
|
|
|
TMP_DIR=""
|
|
POLICY_FILE=""
|
|
SSH_CONFIG=""
|
|
SSH_HOST=""
|
|
|
|
BOLD='\033[1m'
|
|
DIM='\033[2m'
|
|
CYAN='\033[36m'
|
|
GREEN='\033[32m'
|
|
RED='\033[31m'
|
|
YELLOW='\033[33m'
|
|
RESET='\033[0m'
|
|
|
|
step() {
|
|
printf "\n${BOLD}${CYAN}==> %s${RESET}\n\n" "$1"
|
|
}
|
|
|
|
info() {
|
|
printf " %b\n" "$*"
|
|
}
|
|
|
|
fail() {
|
|
printf "\n${RED}error:${RESET} %s\n" "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
|
|
if [[ "$DEMO_KEEP_SANDBOX" != "1" ]]; then
|
|
"$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true
|
|
else
|
|
printf "\n${YELLOW}Keeping sandbox because DEMO_KEEP_SANDBOX=1: %s${RESET}\n" "$DEMO_SANDBOX_NAME"
|
|
fi
|
|
|
|
"$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true
|
|
|
|
if [[ -z "$TMP_DIR" ]]; then
|
|
return
|
|
fi
|
|
|
|
if [[ $status -eq 0 ]]; then
|
|
rm -rf "$TMP_DIR"
|
|
else
|
|
printf "\n${YELLOW}Temporary files kept at: %s${RESET}\n" "$TMP_DIR"
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
require_command() {
|
|
command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1"
|
|
}
|
|
|
|
validate_name() {
|
|
local label="$1"
|
|
local value="$2"
|
|
[[ "$value" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "$label may contain only letters, numbers, '.', '_', and '-'"
|
|
}
|
|
|
|
validate_path() {
|
|
local label="$1"
|
|
local value="$2"
|
|
[[ "$value" =~ ^[A-Za-z0-9._/-]+$ ]] || fail "$label may contain only letters, numbers, '.', '_', '-', and '/'"
|
|
[[ "$value" != /* ]] || fail "$label must be relative"
|
|
[[ "$value" != *..* ]] || fail "$label must not contain '..'"
|
|
}
|
|
|
|
resolve_token() {
|
|
if [[ -z "${DEMO_GITHUB_TOKEN:-}" ]]; then
|
|
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
|
DEMO_GITHUB_TOKEN="$GITHUB_TOKEN"
|
|
elif [[ -n "${GH_TOKEN:-}" ]]; then
|
|
DEMO_GITHUB_TOKEN="$GH_TOKEN"
|
|
elif command -v gh >/dev/null 2>&1; then
|
|
DEMO_GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)"
|
|
fi
|
|
fi
|
|
|
|
[[ -n "${DEMO_GITHUB_TOKEN:-}" ]] || fail "set DEMO_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN, or sign in with gh"
|
|
export GITHUB_TOKEN="$DEMO_GITHUB_TOKEN"
|
|
}
|
|
|
|
validate_env() {
|
|
require_command curl
|
|
require_command jq
|
|
require_command ssh
|
|
require_command "$OPENSHELL_BIN"
|
|
|
|
[[ -f "$RUNNER_SOURCE" ]] || fail "missing sandbox runner: $RUNNER_SOURCE"
|
|
[[ -n "${DEMO_GITHUB_OWNER:-}" ]] || fail "set DEMO_GITHUB_OWNER"
|
|
[[ -n "${DEMO_GITHUB_REPO:-}" ]] || fail "set DEMO_GITHUB_REPO"
|
|
[[ "$DEMO_RUN_ID" =~ ^[a-z0-9-]+$ ]] || fail "DEMO_RUN_ID may contain only lowercase letters, numbers, and '-'"
|
|
[[ "$DEMO_RETRY_ATTEMPTS" =~ ^[0-9]+$ ]] || fail "DEMO_RETRY_ATTEMPTS must be a number"
|
|
[[ "$DEMO_RETRY_SLEEP" =~ ^[0-9]+$ ]] || fail "DEMO_RETRY_SLEEP must be a number"
|
|
|
|
validate_name "DEMO_GITHUB_OWNER" "$DEMO_GITHUB_OWNER"
|
|
validate_name "DEMO_GITHUB_REPO" "$DEMO_GITHUB_REPO"
|
|
validate_path "DEMO_BRANCH" "$DEMO_BRANCH"
|
|
validate_path "DEMO_FILE_PATH" "$DEMO_FILE_PATH"
|
|
|
|
resolve_token
|
|
}
|
|
|
|
github_api_status() {
|
|
local url="$1"
|
|
local body="$2"
|
|
curl -sS \
|
|
-o "$body" \
|
|
-w "%{http_code}" \
|
|
-H "Accept: application/vnd.github+json" \
|
|
-H "Authorization: Bearer ${DEMO_GITHUB_TOKEN}" \
|
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
"$url"
|
|
}
|
|
|
|
check_gateway() {
|
|
step "Checking active OpenShell gateway"
|
|
if ! "$OPENSHELL_BIN" status >/dev/null 2>&1; then
|
|
fail "active OpenShell gateway is not reachable; start one separately, for example: mise run cluster"
|
|
fi
|
|
"$OPENSHELL_BIN" status | sed 's/^/ /'
|
|
}
|
|
|
|
check_github_access() {
|
|
step "Checking GitHub repository access"
|
|
local body status branch branches_body branches_status branches
|
|
body="${TMP_DIR}/github-repo.json"
|
|
status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}" "$body")"
|
|
|
|
if [[ "$status" != "200" ]]; then
|
|
printf '%s\n' "$(jq -r '.message // empty' "$body" 2>/dev/null)" | sed 's/^/ /'
|
|
fail "GitHub returned HTTP $status for ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}; check the repo name and token access"
|
|
fi
|
|
|
|
if jq -e 'has("permissions") and (.permissions.push == false and .permissions.admin == false and .permissions.maintain == false)' "$body" >/dev/null; then
|
|
fail "GitHub token can read ${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO} but does not appear to have write access"
|
|
fi
|
|
|
|
branch="$(jq -rn --arg v "$DEMO_BRANCH" '$v|@uri')"
|
|
body="${TMP_DIR}/github-branch.json"
|
|
status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/branches/${branch}" "$body")"
|
|
if [[ "$status" != "200" ]]; then
|
|
branches_body="${TMP_DIR}/github-branches.json"
|
|
branches_status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/branches?per_page=20" "$branches_body")"
|
|
if [[ "$branches_status" == "200" ]]; then
|
|
branches="$(jq -r 'map(.name) | join(", ")' "$branches_body")"
|
|
if [[ -z "$branches" ]]; then
|
|
fail "GitHub repo exists but has no branches yet; add an initial README or push ${DEMO_BRANCH} before running the demo"
|
|
fi
|
|
fail "GitHub returned HTTP $status for branch ${DEMO_BRANCH}; set DEMO_BRANCH to one of: ${branches}"
|
|
fi
|
|
fail "GitHub returned HTTP $status for branch ${DEMO_BRANCH}"
|
|
fi
|
|
|
|
body="${TMP_DIR}/github-demo-file.json"
|
|
status="$(github_api_status "https://api.github.com/repos/${DEMO_GITHUB_OWNER}/${DEMO_GITHUB_REPO}/contents/${DEMO_FILE_PATH}?ref=${branch}" "$body")"
|
|
if [[ "$status" == "200" ]]; then
|
|
fail "validation output file already exists: ${DEMO_FILE_PATH}; choose a new DEMO_RUN_ID or DEMO_FILE_PATH"
|
|
fi
|
|
[[ "$status" == "404" ]] || fail "GitHub returned HTTP $status while checking demo output path ${DEMO_FILE_PATH}"
|
|
|
|
info "${GREEN}GitHub repo, branch, and output path are safe for this run.${RESET}"
|
|
}
|
|
|
|
create_provider() {
|
|
step "Creating temporary GitHub provider"
|
|
"$OPENSHELL_BIN" provider delete "$DEMO_GITHUB_PROVIDER_NAME" >/dev/null 2>&1 || true
|
|
"$OPENSHELL_BIN" provider create \
|
|
--name "$DEMO_GITHUB_PROVIDER_NAME" \
|
|
--type github \
|
|
--credential GITHUB_TOKEN
|
|
}
|
|
|
|
check_agent_proposals_enabled() {
|
|
step "Checking agent-driven policy proposal opt-in"
|
|
local value
|
|
value="$("$OPENSHELL_BIN" settings get --global --json 2>/dev/null \
|
|
| jq -r '.settings.agent_policy_proposals_enabled // "<unset>"')"
|
|
if [[ "$value" != "true" ]]; then
|
|
fail "agent_policy_proposals_enabled must be true before running this test.
|
|
Enable it with:
|
|
$OPENSHELL_BIN settings set --global --key agent_policy_proposals_enabled --value true --yes"
|
|
fi
|
|
info "${GREEN}agent_policy_proposals_enabled=true${RESET}"
|
|
}
|
|
|
|
create_temp_workspace() {
|
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-policy.XXXXXX")"
|
|
POLICY_FILE="${TMP_DIR}/policy.yaml"
|
|
SSH_CONFIG="${TMP_DIR}/ssh_config"
|
|
}
|
|
|
|
create_sandbox() {
|
|
step "Creating sandbox with read-only GitHub L7 policy"
|
|
cp "$POLICY_TEMPLATE" "$POLICY_FILE"
|
|
"$OPENSHELL_BIN" sandbox delete "$DEMO_SANDBOX_NAME" >/dev/null 2>&1 || true
|
|
"$OPENSHELL_BIN" sandbox create \
|
|
--name "$DEMO_SANDBOX_NAME" \
|
|
--provider "$DEMO_GITHUB_PROVIDER_NAME" \
|
|
--policy "$POLICY_FILE" \
|
|
--upload "${RUNNER_SOURCE}:/sandbox/policy-validation-runner.sh" \
|
|
--no-git-ignore \
|
|
--keep \
|
|
--no-auto-providers \
|
|
--no-tty \
|
|
-- bash -lc "chmod +x /sandbox/policy-validation-runner.sh && echo sandbox ready"
|
|
}
|
|
|
|
connect_ssh() {
|
|
step "Connecting to sandbox over SSH"
|
|
"$OPENSHELL_BIN" sandbox ssh-config "$DEMO_SANDBOX_NAME" > "$SSH_CONFIG"
|
|
SSH_HOST="$(awk '/^Host / { print $2; exit }' "$SSH_CONFIG")"
|
|
[[ -n "$SSH_HOST" ]] || fail "could not find Host entry in sandbox SSH config"
|
|
|
|
local retries=30
|
|
local i
|
|
for i in $(seq 1 "$retries"); do
|
|
if ssh -F "$SSH_CONFIG" "$SSH_HOST" true >/dev/null 2>&1; then
|
|
return
|
|
fi
|
|
sleep 2
|
|
done
|
|
fail "SSH connection to sandbox timed out"
|
|
}
|
|
|
|
sandbox_exec() {
|
|
ssh -F "$SSH_CONFIG" "$SSH_HOST" "$@"
|
|
}
|
|
|
|
http_status() {
|
|
awk -F= '/^HTTP_STATUS=/ { print $2; exit }'
|
|
}
|
|
|
|
http_body() {
|
|
sed '/^HTTP_STATUS=/d'
|
|
}
|
|
|
|
run_policy_local_checks() {
|
|
step "Checking sandbox-local skill and policy.local"
|
|
sandbox_exec /sandbox/policy-validation-runner.sh check-skill >/dev/null
|
|
info "${GREEN}Skill installed:${RESET} /etc/openshell/skills/policy_advisor.md"
|
|
|
|
local output
|
|
output="$(sandbox_exec /sandbox/policy-validation-runner.sh current-policy)"
|
|
local status
|
|
status="$(printf '%s\n' "$output" | http_status)"
|
|
[[ "$status" == "200" ]] || fail "policy.local current policy returned HTTP $status"
|
|
|
|
info "${GREEN}policy.local returned the current sandbox policy.${RESET}"
|
|
info "Initial policy: read-only REST access to api.github.com for /usr/bin/curl"
|
|
}
|
|
|
|
attempt_write() {
|
|
sandbox_exec /sandbox/policy-validation-runner.sh put-file \
|
|
"$DEMO_GITHUB_OWNER" \
|
|
"$DEMO_GITHUB_REPO" \
|
|
"$DEMO_BRANCH" \
|
|
"$DEMO_FILE_PATH" \
|
|
"$DEMO_RUN_ID"
|
|
}
|
|
|
|
submit_policy_proposal() {
|
|
sandbox_exec /sandbox/policy-validation-runner.sh submit-proposal \
|
|
"$DEMO_GITHUB_OWNER" \
|
|
"$DEMO_GITHUB_REPO" \
|
|
"$DEMO_FILE_PATH"
|
|
}
|
|
|
|
capture_initial_denial() {
|
|
step "Attempting GitHub contents write from inside sandbox"
|
|
local output
|
|
output="$(attempt_write)"
|
|
local status
|
|
local body
|
|
status="$(printf '%s\n' "$output" | http_status)"
|
|
body="$(printf '%s\n' "$output" | http_body)"
|
|
|
|
[[ "$status" == "403" ]] || fail "expected OpenShell HTTP 403, got HTTP $status"
|
|
printf '%s\n' "$body" | jq -e '.error == "policy_denied"' >/dev/null \
|
|
|| fail "expected structured policy_denied body"
|
|
printf '%s\n' "$body" | jq -e '.layer == "l7" and .protocol == "rest" and .method == "PUT"' >/dev/null \
|
|
|| fail "expected structured L7 REST deny fields"
|
|
|
|
printf '%s\n' "$body" | jq -r '
|
|
"Denied: \(.method) \(.path)",
|
|
"Layer: \(.layer)/\(.protocol) host=\(.host):\(.port) binary=\(.binary)",
|
|
"Agent guidance: \(.next_steps | map(.action) | join(" -> "))"
|
|
' | sed 's/^/ /'
|
|
info "${GREEN}Captured structured L7 policy denial.${RESET}"
|
|
}
|
|
|
|
submit_and_approve() {
|
|
step "Submitting proposal through policy.local"
|
|
local output
|
|
output="$(submit_policy_proposal)"
|
|
local status
|
|
local body
|
|
status="$(printf '%s\n' "$output" | http_status)"
|
|
body="$(printf '%s\n' "$output" | http_body)"
|
|
|
|
[[ "$status" == "202" ]] || fail "expected proposal submit HTTP 202, got HTTP $status"
|
|
[[ "$(printf '%s\n' "$body" | jq -r '.accepted_chunks // 0')" != "0" ]] \
|
|
|| fail "proposal was not accepted"
|
|
printf '%s\n' "$body" | jq -r '"Proposal submitted: \(.accepted_chunks) accepted, \(.rejected_chunks) rejected"' | sed 's/^/ /'
|
|
|
|
step "Approving pending draft rule from outside the sandbox"
|
|
"$OPENSHELL_BIN" rule get "$DEMO_SANDBOX_NAME" --status pending | sed 's/^/ /'
|
|
"$OPENSHELL_BIN" rule approve-all "$DEMO_SANDBOX_NAME" | sed 's/^/ /'
|
|
}
|
|
|
|
print_success_summary() {
|
|
jq '{
|
|
path: .content.path,
|
|
html_url: .content.html_url,
|
|
commit: .commit.sha,
|
|
message: .commit.message
|
|
}'
|
|
}
|
|
|
|
retry_until_allowed() {
|
|
step "Retrying GitHub contents write after approval"
|
|
local output status body attempt
|
|
|
|
for attempt in $(seq 1 "$DEMO_RETRY_ATTEMPTS"); do
|
|
output="$(attempt_write)"
|
|
status="$(printf '%s\n' "$output" | http_status)"
|
|
body="$(printf '%s\n' "$output" | http_body)"
|
|
|
|
if printf '%s\n' "$body" | jq -e '.error == "policy_denied"' >/dev/null 2>&1; then
|
|
info "${DIM}Attempt ${attempt}/${DEMO_RETRY_ATTEMPTS}: policy not loaded yet; retrying...${RESET}"
|
|
sleep "$DEMO_RETRY_SLEEP"
|
|
continue
|
|
fi
|
|
|
|
if [[ "$status" == "200" || "$status" == "201" ]]; then
|
|
printf '%s\n' "$body" | print_success_summary | sed 's/^/ /'
|
|
info "${GREEN}GitHub write succeeded from inside the sandbox.${RESET}"
|
|
return
|
|
fi
|
|
|
|
printf '%s\n' "$body" | jq . | sed 's/^/ /'
|
|
if [[ "$status" == "404" ]]; then
|
|
fail "policy allowed the request, but GitHub returned HTTP 404; check DEMO_GITHUB_OWNER, DEMO_GITHUB_REPO, and token access"
|
|
fi
|
|
fail "policy allowed the request, but GitHub returned HTTP $status"
|
|
done
|
|
|
|
fail "timed out waiting for approved policy to load into the sandbox"
|
|
}
|
|
|
|
show_logs() {
|
|
step "Policy decision trace"
|
|
"$OPENSHELL_BIN" logs "$DEMO_SANDBOX_NAME" --since 5m -n 50 2>&1 \
|
|
| grep -E 'HTTP:PUT|CONFIG:LOADED|ReportPolicyStatus' \
|
|
| tail -n 8 \
|
|
| sed 's/^/ /' || true
|
|
}
|
|
|
|
main() {
|
|
validate_env
|
|
check_gateway
|
|
check_agent_proposals_enabled
|
|
create_temp_workspace
|
|
check_github_access
|
|
create_provider
|
|
create_sandbox
|
|
connect_ssh
|
|
run_policy_local_checks
|
|
capture_initial_denial
|
|
submit_and_approve
|
|
retry_until_allowed
|
|
show_logs
|
|
|
|
printf "\n${BOLD}${GREEN}✓ Validation complete.${RESET}\n\n"
|
|
printf " Sandbox: %s\n" "$DEMO_SANDBOX_NAME"
|
|
printf " Repository: https://github.com/%s/%s\n" "$DEMO_GITHUB_OWNER" "$DEMO_GITHUB_REPO"
|
|
printf " File: %s\n" "$DEMO_FILE_PATH"
|
|
}
|
|
|
|
main "$@"
|