Skip to content
Cockpit0.29.0
A snapshot: read at commit 707a04c7ce8 on 2026-10-18T00:06:01Z by majordomus 0.29.0. Nothing on this page is read again. See it live

test/cases/93_scope_policy.sh

An object of kind test

What it is

URI
majordomus://test/test/cases/93_scope_policy.sh
Kind
test
Identity
test/cases/93_scope_policy.sh
Path
test/cases/93_scope_policy.sh
Provenance
declared
Media type
text/plain
As a capability
test.test/cases/93_scope_policy.sh

Front matter

{}

Content

# majordomus-covers: none
# claims: scope-declared
# The repository scope through the built executable and the shell tool, in a repository
# the shell tool's own `init` wrote: the seeded declaration passes doctor and is the one
# the executable reads; a source outside the scope (a document over the limit, a binary
# with a Markdown name, a secret, an undeclared root file) is discovered and dropped with
# the reason, never listed as a resource, never served; every path is answered in or out
# with the rule that decided, through the command line and over real MCP pipes; a
# malformed declaration is refused by both tools naming the key; and a repository whose
# manifest names no scope is read under the distribution's default, which is said.
#
# Skips itself when cargo is absent, as the other Rust cases do.
. "$ROOT/test/lib.sh"
command -v jq >/dev/null 2>&1 || skip "jq not installed"
S="$(mktemp -d "${TMPDIR:-/tmp}/mj93.XXXXXX")"; trap 'rm -rf "$S"' EXIT
# the executable MAJORDOMUS_BIN names, or the one rust_bin builds when it names none
RB="$(rust_bin)" || rust_bin_exit $?
MAJORDOMUS_SHARE="$ROOT/share"; export MAJORDOMUS_SHARE

# --- the distribution seeds the declaration, and the two tools agree it is well-formed
"$MJ" init >/dev/null
# doctor is healthy only with the hooks wired and the budget out of the way, as case 83 does;
# the hooks name this tree's executable and are installed after the commits, so that git
# never runs whatever `majordomus` is on PATH
sed -i.bak 's/^    doctor_ms: .*$/    doctor_ms: 600000/; s/^    watch_ms: .*$/    watch_ms: 600000/' .ai/repo/policy.yaml; rm -f .ai/repo/policy.yaml.bak
"$MJ" update >/dev/null
[ -f .ai/repo/scope.yaml ] || { echo "    init seeded no .ai/repo/scope.yaml"; exit 1; }
grep -q '^  scope: repo/scope.yaml$' .ai/manifest.yaml || { echo "    the seeded manifest names no scope section"; exit 1; }
# this fixture has no stack marker at its root, so the seed is the distribution's default
# without its stack blocks (case 3016 covers the stacks)
sed '/^  # stack: /,/^  # end stack: /d' "$ROOT/share/skeleton/ai/repo/scope.yaml" > "$S/scope.expected"
cmp -s .ai/repo/scope.yaml "$S/scope.expected" || { echo "    the seeded scope is not the distribution's default less its stack blocks"; diff "$S/scope.expected" .ai/repo/scope.yaml; exit 1; }
git add -A >/dev/null && git commit -qm install
hooks_on()  { printf '#!/usr/bin/env bash\n%s doctor\n' "$MJ" > .git/hooks/pre-commit; printf '#!/usr/bin/env bash\n%s finish --check\n' "$MJ" > .git/hooks/pre-push; chmod +x .git/hooks/pre-commit .git/hooks/pre-push; }
hooks_off() { rm -f .git/hooks/pre-commit .git/hooks/pre-push; }
hooks_on
expect_exit 0 "$MJ" doctor
expect_grep '^OK   layout      \.ai/repo/scope\.yaml .* version 1; [0-9]+ in pathspec.s.; every key is one the schema declares'
expect_exit 0 "$RB" scope
expect_grep '^scope        \.ai/repo/scope\.yaml \(repository\)$'
expect_grep '^tracked      [0-9]+ file.s.: [0-9]+ in, [0-9]+ out$'
# the allow-list the shell tool checks keys against is generated from the schema, not written
expect_exit 0 "$RB" generate allow --check

# --- sources outside the scope are discovered, dropped with the reason, and never served
mkdir -p docs test/fixtures
printf '# Big\n\n' > docs/big.md; head -c 1100000 /dev/zero | tr '\0' 'x' >> docs/big.md
printf '# Blob\n\0\1\2\n' > docs/blob.md
printf 'TOKEN=1\n' > docs/.env
printf 'not a document\n' > NOTES.md
printf '{"padding": "%s"}\n' "$(head -c 70000 /dev/zero | tr '\0' 'y')" > test/fixtures/large.json
printf '{}\n' > test/fixtures/small.json
hooks_off; git add -A >/dev/null && git commit -qm "outside the scope"; hooks_on
before="$(git status --porcelain; git ls-files -s | mj_sha256sum)"
expect_exit 0 "$RB" mcp --inspect
expect_no_grep 'majordomus://document/docs/big\.md'
expect_no_grep 'majordomus://document/docs/blob\.md'
expect_no_grep 'majordomus://document/NOTES\.md'
"$RB" mcp --inspect --format json 2>/dev/null > "$S/inspect.json"
jq -e '[.repository.diagnostics[] | select(.code == "out_of_scope") | .path] | (index("docs/big.md") != null) and (index("docs/blob.md") != null) and (index("NOTES.md") != null)' "$S/inspect.json" >/dev/null \
  || { echo "    the dropped sources are not reported as out_of_scope:"; jq '.diagnostics' "$S/inspect.json"; exit 1; }
jq -e '.repository.diagnostics[] | select(.path == "docs/big.md") | .message | test("over_limit") and test("max_bytes 1048576")' "$S/inspect.json" >/dev/null \
  || { echo "    the oversized document does not name its limit"; exit 1; }
jq -e '.repository.diagnostics[] | select(.path == "docs/blob.md") | .message | test("binary")' "$S/inspect.json" >/dev/null \
  || { echo "    the binary with a Markdown name is not reported as binary"; exit 1; }
jq -e '.repository.diagnostics[] | select(.path == "NOTES.md") | .message | test("undeclared")' "$S/inspect.json" >/dev/null \
  || { echo "    the undeclared root file is not reported as undeclared"; exit 1; }
jq -e '.repository.diagnostics[] | select(.code == "tracked_secret") | .path == "docs/.env"' "$S/inspect.json" >/dev/null \
  || { echo "    the tracked secret is not reported"; exit 1; }
jq -e '.repository.state == "ok"' "$S/inspect.json" >/dev/null || { echo "    a scope exclusion degraded the index; it is a warning, not an error"; exit 1; }

# --- every path is answered, in or out, with the rule that decided
expect_exit 0 "$RB" scope docs/CLI.md docs/big.md docs/blob.md docs/.env NOTES.md test/fixtures/large.json test/fixtures/small.json .ai/local/state/current.yaml target/debug/x
expect_grep '^in                        docs/CLI\.md  \(docs/\*\*\)'
expect_grep '^out  over_limit           docs/big\.md  \(max_bytes 1048576\)'
expect_grep '^out  binary               docs/blob\.md  \(binary\)'
expect_grep '^out  secret               docs/\.env  \(\.env\)'
expect_grep '^out  undeclared           NOTES\.md'
expect_grep '^out  fixture_over_limit   test/fixtures/large\.json  \(\*\*/fixtures/ over fixtures\.max_bytes 65536\)'
expect_grep '^in                        test/fixtures/small\.json  \(test/\*\*\)'
expect_grep '^out  path                 \.ai/local/state/current\.yaml  \(\.ai/local/\)'
expect_grep '^out  path                 target/debug/x  \(\*\*/target/\)  \[absent\]'
expect_exit 10 "$RB" scope --check docs/CLI.md docs/.env
expect_exit 0 "$RB" scope --check docs/CLI.md
"$RB" scope --format json docs/.env 2>/dev/null | jq -e '.[0] | .verdict == "out" and .reason == "secret" and .rule == ".env" and .exists == true' >/dev/null \
  || { echo "    the JSON answer is wrong"; exit 1; }
expect_exit 13 "$RB" scope /etc/passwd
expect_grep 'absolute'

# --- the same answers over real MCP pipes: the tool, and the scope as a resource
req() { printf '{"jsonrpc":"2.0","id":%s,"method":"%s"%s}\n' "$1" "$2" "${3:+,\"params\":$3}"; }
{
  req 1 initialize '{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"case93","version":"0"}}'
  printf '{"jsonrpc":"2.0","method":"notifications/initialized"}\n'
  req 2 tools/call '{"name":"majordomus_scope_classify","arguments":{"path":"docs/.env"}}'
  req 3 resources/read '{"uri":"majordomus://scope"}'
  req 4 tools/call '{"name":"majordomus_get","arguments":{"uri":"majordomus://document/docs/big.md"}}'
  req 5 tools/call '{"name":"majordomus_scope_classify","arguments":{"path":"../etc/passwd"}}'
} > "$S/session.in"
rc=0; "$RB" mcp --standalone < "$S/session.in" > "$S/session.out" 2> "$S/session.err" || rc=$?
[ "$rc" = 0 ] || { echo "    the server exited $rc at EOF"; cat "$S/session.err"; exit 1; }
sed -n 2p "$S/session.out" | jq -e '.result.structuredContent.reason == "secret" and .result.structuredContent.verdict == "out"' >/dev/null \
  || { echo "    majordomus_scope_classify answered wrong: $(sed -n 2p "$S/session.out")"; exit 1; }
sed -n 3p "$S/session.out" | jq -e '.result.contents[0].text | fromjson | .origin == "repository" and .path == ".ai/repo/scope.yaml" and (.tracked.by_reason.secret == 1)' >/dev/null \
  || { echo "    majordomus://scope is not the scope report: $(sed -n 3p "$S/session.out")"; exit 1; }
sed -n 4p "$S/session.out" | jq -e '(.error != null) or (.result.isError == true)' >/dev/null \
  || { echo "    an object outside the scope was served: $(sed -n 4p "$S/session.out")"; exit 1; }
sed -n 5p "$S/session.out" | jq -e '(.error != null) or (.result.isError == true)' >/dev/null \
  || { echo "    a path outside the repository was judged: $(sed -n 5p "$S/session.out")"; exit 1; }
after="$(git status --porcelain; git ls-files -s | mj_sha256sum)"
[ "$before" = "$after" ] || { echo "    the executable changed the tracked tree"; git status --porcelain; exit 1; }

# --- a malformed declaration is refused by both tools, naming the key
cp .ai/repo/scope.yaml "$S/scope.good"
printf '  colour: red\n' >> .ai/repo/scope.yaml
expect_exit 10 "$MJ" doctor
expect_grep '^FAIL layout      \.ai/repo/scope\.yaml .* unknown key.s.: out\.colour'
expect_exit 10 "$RB" scope
expect_grep 'scope\.yaml.*colour'
cp "$S/scope.good" .ai/repo/scope.yaml
sed -i.bak 's|^  - docs/\*\*$|  - /docs/**|' .ai/repo/scope.yaml && rm -f .ai/repo/scope.yaml.bak
expect_exit 10 "$RB" scope
expect_grep "/docs/\*\*.*relative"
cp "$S/scope.good" .ai/repo/scope.yaml

# --- a manifest naming no scope: the distribution's default applies, and both tools say so
git rm -q .ai/repo/scope.yaml
sed -i.bak '/^  scope: repo\/scope\.yaml$/d' .ai/manifest.yaml && rm -f .ai/manifest.yaml.bak
hooks_off; git add -A >/dev/null && git commit -qm "no scope"; hooks_on
expect_exit 0 "$MJ" doctor
expect_grep 'INFO layout      \.ai/repo/scope\.yaml .* the manifest names no scope section; the executable reads the distribution.s default'
expect_exit 0 "$RB" scope
expect_grep '^scope        .*share/skeleton/ai/repo/scope\.yaml \(distribution\)$'
# and --extend seeds the file back, saying the manifest must name it
expect_exit 0 "$MJ" init --extend
expect_grep 'names no scope section'
[ -f .ai/repo/scope.yaml ] || { echo "    init --extend did not seed the scope"; exit 1; }