#!/usr/bin/env bash
# fs-schema-guard — PreToolUse Write/Edit/NotebookEdit path guard.
#
# Enforces the seeded account-dir file schema (see <accountDir>/SCHEMA.md):
#   - the target's first path segment must be in the allowed top-level set
#     (parsed from the fenced ```allowed-top-level block of the account's
#     SCHEMA.md — our own structured data, not CLI prose);
#   - a target under an operator-data bucket (projects/ contacts/) may be at
#     most <bucket>/<entity>/<file> deep; documents/ may be at most
#     <bucket>/<folder>/<file> deep. Tool-owned dirs pass at any depth.
#
# The hook governs the account dir only. A path resolving outside the account
# dir (cwd) is allowed — platform-source writes are PLATFORM_BOUNDARY's concern.
#
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
# the tool call cannot be inspected (tty or empty stdin). Every block logs
#   [fs-guard] blocked path=<rel> reason=<top-level|over-deep|bad-name>
# No task numbers / internal refs in any operator-visible string.

set -uo pipefail

# Fail closed if attached to a terminal (no JSON envelope coming).
if [ -t 0 ]; then
  echo "Blocked: fs-schema-guard received no stdin (cannot inspect the write). Failing closed." >&2
  exit 2
fi
INPUT=$(cat)
if [ -z "$INPUT" ]; then
  echo "Blocked: fs-schema-guard received no stdin (cannot inspect the write). Failing closed." >&2
  exit 2
fi

field() {
  printf '%s' "$INPUT" | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    v = (d.get('$1', {}) or {}).get('$2', '') if '$1' else d.get('$2', '')
    print(v if isinstance(v, str) else '')
except Exception:
    print('')
" 2>/dev/null || echo ""
}

TOOL_NAME=$(field '' tool_name)
case "$TOOL_NAME" in
  Write|Edit) FILE_PATH=$(field tool_input file_path) ;;
  NotebookEdit) FILE_PATH=$(field tool_input notebook_path) ;;
  *) exit 0 ;;
esac
[ -z "$FILE_PATH" ] && exit 0

ACCOUNT_DIR="$PWD"

# Resolve to an account-relative path; allow anything outside the account dir.
REL=$(FILE_PATH="$FILE_PATH" ACCOUNT_DIR="$ACCOUNT_DIR" python3 -c '
import os, sys
fp = os.environ["FILE_PATH"]; acct = os.environ["ACCOUNT_DIR"]
ab = fp if os.path.isabs(fp) else os.path.join(acct, fp)
ab = os.path.normpath(ab); acct = os.path.normpath(acct)
if ab == acct or not ab.startswith(acct + os.sep):
    print("")  # outside the account dir (or the dir itself)
else:
    print(os.path.relpath(ab, acct))
' 2>/dev/null || echo "")
[ -z "$REL" ] && exit 0

# Parse the allowed top-level set from the account's seeded SCHEMA.md.
ALLOWED=""
if [ -f "$ACCOUNT_DIR/SCHEMA.md" ]; then
  ALLOWED=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
fi
# Fail open on a missing/empty schema (an un-seeded legacy account must not have
# every write blocked). A re-provision seeds SCHEMA.md, after which the guard
# applies.
[ -z "$ALLOWED" ] && exit 0

SEG0=${REL%%/*}

# Top-level check.
if ! printf '%s\n' "$ALLOWED" | grep -qxF "$SEG0"; then
  echo "[fs-guard] blocked path=$REL reason=top-level" >&2
  echo "Blocked: '$SEG0' is not an allowed top-level entry in this account's data directory. The layout is fixed by SCHEMA.md — place operator data under projects/, contacts/, or documents/, and route any reorganisation through the data-manager specialist." >&2
  exit 2
fi

# Over-deep check for operator-data buckets. Count path segments.
depth=$(printf '%s' "$REL" | awk -F/ '{print NF}')
case "$SEG0" in
  projects|contacts)
    # Allowed: <bucket>/<entity>/<file> = 3 segments max; <bucket>/<file> = 2 ok.
    if [ "$depth" -gt 3 ]; then
      echo "[fs-guard] blocked path=$REL reason=over-deep" >&2
      echo "Blocked: $SEG0/ is flat — one folder per entity, then files. '$REL' nests a subtree under an entity folder, which has no basis in the graph ontology. Keep files directly under $SEG0/<name>/, or route a reorganisation through the data-manager specialist." >&2
      exit 2
    fi
    ;;
  documents)
    # Allowed: documents/<file> = 2, documents/<folder>/<file> = 3 max.
    if [ "$depth" -gt 3 ]; then
      echo "[fs-guard] blocked path=$REL reason=over-deep" >&2
      echo "Blocked: documents/ holds files or one folder deep. '$REL' nests deeper, which has no basis in the graph ontology. Route a reorganisation through the data-manager specialist." >&2
      exit 2
    fi
    ;;
esac

# Name-convention check for the operator-data buckets, after top-level and depth
# pass. The operator-authored segments carry a character rule: folders are
# [a-z0-9-]; the write target (last segment, always a file) is lowercase
# dot-segments so a lowercase extension is allowed. The bucket segment (top-level
# rule) and dot-prefixed entries (skipped at every level system-wide) are exempt.
# Space-/case-/underscore-bearing names break shell paths, data-portal URL
# minting, and case-sensitive matching.
case "$SEG0" in
  projects|contacts|documents)
    BADSEG=$(REL="$REL" python3 -c '
import os, re
segs = os.environ["REL"].split("/")
folder_re = re.compile(r"^[a-z0-9-]+\Z")
file_re = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*\Z")
bad = ""
for i, s in enumerate(segs):
    if i == 0 or s.startswith("."):
        continue  # bucket segment (top-level rule), or a dot-prefixed entry
    ok = file_re.match(s) if i == len(segs) - 1 else folder_re.match(s)
    if not ok:
        bad = s
        break
print(bad)
' 2>/dev/null || echo "")
    if [ -n "$BADSEG" ]; then
      echo "[fs-guard] blocked path=$REL reason=bad-name" >&2
      echo "Blocked: '$BADSEG' is not a valid name in this account's data directory. Folder and file names under projects/, contacts/, and documents/ use lowercase letters, digits, and hyphens only, with a lowercase file extension. Rename it to fit, or route a reorganisation through the data-manager specialist." >&2
      exit 2
    fi
    ;;
esac

exit 0
