@rubytech/create-maxy-code 0.1.485 → 0.1.487
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/docs/superpowers/plans/2026-07-21-graph-caption-resolver.md +124 -0
- package/payload/platform/docs/superpowers/plans/2026-07-21-task-1877-bash-schema-enforcement.md +107 -0
- package/payload/platform/docs/superpowers/plans/2026-07-21-task-1887-reconcile-allowed-top-level.md +205 -0
- package/payload/platform/lib/graph-style/dist/index.d.ts +4 -1
- package/payload/platform/lib/graph-style/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/graph-style/dist/index.js +109 -1
- package/payload/platform/lib/graph-style/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-style/src/__tests__/caption-coverage.test.ts +117 -0
- package/payload/platform/lib/graph-style/src/index.ts +81 -1
- package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard-bash.test.sh +159 -0
- package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +18 -6
- package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-post.sh +141 -0
- package/payload/platform/plugins/admin/hooks/fs-schema-guard-bash-pre.sh +66 -0
- package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +33 -1
- package/payload/platform/plugins/admin/skills/professional-document/SKILL.md +3 -3
- package/payload/platform/plugins/cloudflare/PLUGIN.md +5 -3
- package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +23 -2
- package/payload/platform/plugins/cloudflare/bin/portal-index-push.mjs +30 -8
- package/payload/platform/plugins/cloudflare/bin/schema-exposed-dirs.mjs +22 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +2 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-directory-listing.test.ts +49 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +57 -3
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-index-push.test.ts +23 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-indexed-download.test.ts +44 -2
- package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-session-grant.test.ts +67 -0
- package/payload/platform/plugins/cloudflare/mcp/__tests__/schema-exposed-dirs.test.ts +43 -0
- package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +54 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/schema.sql +10 -1
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +19 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +19 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +8 -2
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/index.html +28 -5
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.css +195 -6
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/portal.js +193 -10
- package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +4 -3
- package/payload/platform/plugins/email/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/email/PLUGIN.md +5 -1
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js +50 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-select.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js +51 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/attachment-text.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts +2 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js +89 -0
- package/payload/platform/plugins/email/mcp/dist/__tests__/email-fetch-attachment.test.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/index.js +28 -0
- package/payload/platform/plugins/email/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts +22 -0
- package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js +55 -0
- package/payload/platform/plugins/email/mcp/dist/lib/attachment-text.js.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts +32 -0
- package/payload/platform/plugins/email/mcp/dist/lib/imap.d.ts.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/lib/imap.js +75 -1
- package/payload/platform/plugins/email/mcp/dist/lib/imap.js.map +1 -1
- package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts +19 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.d.ts.map +1 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js +49 -0
- package/payload/platform/plugins/email/mcp/dist/tools/email-fetch-attachment.js.map +1 -0
- package/payload/platform/plugins/email/references/email-reference.md +7 -1
- package/payload/platform/plugins/memory/references/schema-construction.md +18 -12
- package/payload/platform/plugins/memory/references/schema-estate-agent.md +11 -6
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts +2 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js +12 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/__tests__/free-busy-url.test.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts +8 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.d.ts.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js +12 -0
- package/payload/platform/plugins/scheduling/mcp/dist/lib/free-busy-url.js.map +1 -0
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js +12 -2
- package/payload/platform/plugins/scheduling/mcp/dist/scripts/publish-availability.js.map +1 -1
- package/payload/platform/scripts/__tests__/account-schema-owned-dirs.test.sh +75 -6
- package/payload/platform/scripts/lib/account-schema-owned-dirs.py +111 -22
- package/payload/platform/scripts/lib/provision-account-dir.sh +8 -1
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts +39 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js +315 -0
- package/payload/platform/services/claude-session-manager/dist/account-dir-schema-reconcile.js.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js +1 -0
- package/payload/platform/services/claude-session-manager/dist/canonical-tool-names.generated.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/index.js +15 -0
- package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/system-prompt.js +4 -0
- package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +17 -0
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/server/{chunk-XGNRSM57.js → chunk-HN4HFTXE.js} +6 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{admin-rScqRq-R.js → admin-BoVCP4Mw.js} +1 -1
- package/payload/server/public/assets/{data-TOWuhMFb.js → data-CtOxa94P.js} +1 -1
- package/payload/server/public/assets/{graph-labels-Axa04w8A.js → graph-labels-C8-sIKuv.js} +1 -1
- package/payload/server/public/assets/{graph-BEGs_P2m.js → graph-mJ9ky5dc.js} +1 -1
- package/payload/server/public/assets/{page-6CZ8R57f.js → page-Cv0qUwMG.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +2 -2
- package/payload/server/public/index.html +3 -3
- package/payload/server/server.js +231 -103
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fs-schema-guard-bash-post — PostToolUse Bash stray-folder detection.
|
|
3
|
+
#
|
|
4
|
+
# The other half of the Bash-channel net (see fs-schema-guard-bash-pre.sh). After
|
|
5
|
+
# a Bash command runs, this hook re-lists the account root, parses the account's
|
|
6
|
+
# allowed-top-level set, and flags entries that are present NOW, absent from the
|
|
7
|
+
# pre-snapshot, and not allowed — the strays THIS command created. For each it
|
|
8
|
+
# emits exit 2 with a schema-citing message so the agent self-corrects within the
|
|
9
|
+
# same turn.
|
|
10
|
+
#
|
|
11
|
+
# No filesystem mutation: PostToolUse runs after the command executed, so it
|
|
12
|
+
# cannot prevent the mkdir, and auto-deleting a just-created folder risks
|
|
13
|
+
# destroying legitimate in-flight work (auto-repair was rejected). The standing
|
|
14
|
+
# reconcile (account-dir-schema-reconcile.ts) is the net for anything the agent
|
|
15
|
+
# ignores or that predates the hook.
|
|
16
|
+
#
|
|
17
|
+
# Diffing against the pre-snapshot (not flagging every non-allowed entry) is what
|
|
18
|
+
# keeps the hook from firing on the legacy backlog on every unrelated Bash call.
|
|
19
|
+
#
|
|
20
|
+
# Exit codes: 0 = allow, 2 = feedback to the agent (stderr shown). Fail open
|
|
21
|
+
# (exit 0) when the snapshot is missing or the allowed set is empty. Every block
|
|
22
|
+
# logs: [fs-guard-bash] stray path=<name> reason=<top-level|bad-name>
|
|
23
|
+
# No task numbers / internal refs in any operator-visible string.
|
|
24
|
+
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
|
|
27
|
+
# No stdin envelope (tty) — cannot inspect. Fail open (post hooks never block on
|
|
28
|
+
# an un-inspectable call; a false block on every terminal-run Bash is worse than
|
|
29
|
+
# a missed stray the reconcile still catches).
|
|
30
|
+
[ -t 0 ] && exit 0
|
|
31
|
+
INPUT=$(cat)
|
|
32
|
+
[ -z "$INPUT" ] && exit 0
|
|
33
|
+
|
|
34
|
+
field() {
|
|
35
|
+
printf '%s' "$INPUT" | python3 -c "
|
|
36
|
+
import sys, json
|
|
37
|
+
try:
|
|
38
|
+
d = json.load(sys.stdin)
|
|
39
|
+
print(d.get('$1', '') or '')
|
|
40
|
+
except Exception:
|
|
41
|
+
print('')
|
|
42
|
+
" 2>/dev/null || echo ""
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
TOOL_NAME=$(field tool_name)
|
|
46
|
+
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
|
47
|
+
|
|
48
|
+
SESSION_ID=$(field session_id)
|
|
49
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
50
|
+
|
|
51
|
+
if [ -n "${FS_GUARD_BASH_TMPDIR:-}" ]; then
|
|
52
|
+
TMP_DIR="$FS_GUARD_BASH_TMPDIR"
|
|
53
|
+
else
|
|
54
|
+
TMP_DIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
|
|
55
|
+
TMP_DIR="${TMP_DIR%/}"
|
|
56
|
+
fi
|
|
57
|
+
SID_SAN=$(printf '%s' "$SESSION_ID" | sed 's/[^A-Za-z0-9_-]/_/g')
|
|
58
|
+
SNAP="$TMP_DIR/maxy-fsguard-snapshot-${SID_SAN}.txt"
|
|
59
|
+
|
|
60
|
+
# Missing pre-snapshot -> fail open (the pre hook could not run or failed open).
|
|
61
|
+
[ -f "$SNAP" ] || exit 0
|
|
62
|
+
|
|
63
|
+
ACCOUNT_DIR="$PWD"
|
|
64
|
+
|
|
65
|
+
# Parse the allowed top-level set from the account's seeded SCHEMA.md — the same
|
|
66
|
+
# awk extraction fs-schema-guard.sh uses. Empty/absent -> fail open.
|
|
67
|
+
ALLOWED=""
|
|
68
|
+
if [ -f "$ACCOUNT_DIR/SCHEMA.md" ]; then
|
|
69
|
+
ALLOWED=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
|
|
70
|
+
fi
|
|
71
|
+
[ -z "$ALLOWED" ] && exit 0
|
|
72
|
+
|
|
73
|
+
# Compute this command's findings, each tagged `reason<TAB>name`:
|
|
74
|
+
# top-level : an account-root entry present now, absent from the pre-snapshot,
|
|
75
|
+
# not in the allowed set (the original stray check).
|
|
76
|
+
# bad-name : an immediate child of an operator-data bucket (projects/contacts/
|
|
77
|
+
# documents) that is NEW this command whose name breaks the
|
|
78
|
+
# convention. A bucket absent from the pre-snapshot is new, so all
|
|
79
|
+
# its children are this command's doing; flagging them cannot
|
|
80
|
+
# re-spam the legacy backlog, which lives only in pre-existing
|
|
81
|
+
# buckets. Bad names under a pre-existing bucket are the standing
|
|
82
|
+
# reconcile's job (it can distinguish backlog from fresh; this hook
|
|
83
|
+
# cannot, with a top-level-only snapshot). python3 does the set
|
|
84
|
+
# arithmetic and regex (names may contain spaces).
|
|
85
|
+
FINDINGS=$(ACCOUNT_DIR="$ACCOUNT_DIR" SNAP="$SNAP" ALLOWED="$ALLOWED" python3 -c '
|
|
86
|
+
import os, sys, re
|
|
87
|
+
acct = os.environ["ACCOUNT_DIR"]
|
|
88
|
+
try:
|
|
89
|
+
now = set(os.listdir(acct))
|
|
90
|
+
except Exception:
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
with open(os.environ["SNAP"]) as f:
|
|
93
|
+
before = set(l for l in f.read().split("\n") if l != "")
|
|
94
|
+
allowed = set(l.strip() for l in os.environ["ALLOWED"].split("\n") if l.strip())
|
|
95
|
+
|
|
96
|
+
out = []
|
|
97
|
+
for name in sorted((now - before) - allowed):
|
|
98
|
+
out.append("top-level\t" + name)
|
|
99
|
+
|
|
100
|
+
folder_re = re.compile(r"^[a-z0-9-]+\Z")
|
|
101
|
+
file_re = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*\Z")
|
|
102
|
+
for bucket in ("projects", "contacts", "documents"):
|
|
103
|
+
if bucket in before or bucket not in now:
|
|
104
|
+
continue # pre-existing bucket, or not created this command
|
|
105
|
+
bpath = os.path.join(acct, bucket)
|
|
106
|
+
try:
|
|
107
|
+
children = os.listdir(bpath)
|
|
108
|
+
except Exception:
|
|
109
|
+
continue
|
|
110
|
+
for child in sorted(children):
|
|
111
|
+
if child.startswith("."):
|
|
112
|
+
continue # dot-prefixed entries are skipped at every level
|
|
113
|
+
rx = folder_re if os.path.isdir(os.path.join(bpath, child)) else file_re
|
|
114
|
+
if not rx.match(child):
|
|
115
|
+
out.append("bad-name\t" + child)
|
|
116
|
+
|
|
117
|
+
sys.stdout.write("\n".join(out))
|
|
118
|
+
' 2>/dev/null) || exit 0
|
|
119
|
+
|
|
120
|
+
[ -z "$FINDINGS" ] && exit 0
|
|
121
|
+
|
|
122
|
+
# One log line per finding, then a schema-citing feedback block per reason seen.
|
|
123
|
+
# exit 2 feeds stderr back to the agent.
|
|
124
|
+
TOPLEVEL=""
|
|
125
|
+
BADNAME=""
|
|
126
|
+
{
|
|
127
|
+
while IFS=$'\t' read -r reason name; do
|
|
128
|
+
[ -z "$reason" ] && continue
|
|
129
|
+
case "$reason" in
|
|
130
|
+
top-level) echo "[fs-guard-bash] stray path=$name reason=top-level"; TOPLEVEL="$TOPLEVEL $name" ;;
|
|
131
|
+
bad-name) echo "[fs-guard-bash] stray path=$name reason=bad-name"; BADNAME="$BADNAME $name" ;;
|
|
132
|
+
esac
|
|
133
|
+
done <<< "$FINDINGS"
|
|
134
|
+
if [ -n "$TOPLEVEL" ]; then
|
|
135
|
+
echo "Blocked: this Bash command created top-level entries that are not allowed in this account's data directory:$TOPLEVEL. The layout is fixed by SCHEMA.md — only the entries listed there are permitted at the top level. Relocate this content under projects/, contacts/, or documents/, and route any reorganisation through the data-manager specialist."
|
|
136
|
+
fi
|
|
137
|
+
if [ -n "$BADNAME" ]; then
|
|
138
|
+
echo "Blocked: this Bash command created names that break the account's naming rule:$BADNAME. Folder and file names under projects/, contacts/, and documents/ use lowercase letters, digits, and hyphens only, with a lowercase file extension. Rename them, or route a reorganisation through the data-manager specialist."
|
|
139
|
+
fi
|
|
140
|
+
} >&2
|
|
141
|
+
exit 2
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fs-schema-guard-bash-pre — PreToolUse Bash account-root snapshot.
|
|
3
|
+
#
|
|
4
|
+
# The Write/Edit/NotebookEdit path guard (fs-schema-guard.sh) does not see Bash:
|
|
5
|
+
# a folder created through mkdir/mv/cp/redirect bypasses it entirely. This hook
|
|
6
|
+
# is half of the Bash-channel net: it records the account root's immediate
|
|
7
|
+
# top-level entry names BEFORE the Bash command runs, so the PostToolUse twin
|
|
8
|
+
# (fs-schema-guard-bash-post.sh) can diff and flag only the strays THIS command
|
|
9
|
+
# newly created — never the pre-existing legacy backlog.
|
|
10
|
+
#
|
|
11
|
+
# The hook inspects account-root STATE, never the command string (command-string
|
|
12
|
+
# parsing was rejected as brittle and evadable). $PWD is the account dir.
|
|
13
|
+
#
|
|
14
|
+
# Fail open on every error (no session id, no python3, unlistable dir, unwritable
|
|
15
|
+
# tmp): a snapshot that cannot be taken must not block the command. exit 0 always
|
|
16
|
+
# — a PreToolUse snapshot never blocks; blocking is the post hook's job.
|
|
17
|
+
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
|
|
20
|
+
# No stdin envelope (tty) — nothing to snapshot. Fail open.
|
|
21
|
+
[ -t 0 ] && exit 0
|
|
22
|
+
INPUT=$(cat)
|
|
23
|
+
[ -z "$INPUT" ] && exit 0
|
|
24
|
+
|
|
25
|
+
field() {
|
|
26
|
+
printf '%s' "$INPUT" | python3 -c "
|
|
27
|
+
import sys, json
|
|
28
|
+
try:
|
|
29
|
+
d = json.load(sys.stdin)
|
|
30
|
+
print(d.get('$1', '') or '')
|
|
31
|
+
except Exception:
|
|
32
|
+
print('')
|
|
33
|
+
" 2>/dev/null || echo ""
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
TOOL_NAME=$(field tool_name)
|
|
37
|
+
[ "$TOOL_NAME" = "Bash" ] || exit 0
|
|
38
|
+
|
|
39
|
+
SESSION_ID=$(field session_id)
|
|
40
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
41
|
+
|
|
42
|
+
# Resolve the tmpdir the same way the manager's Node process does (libuv
|
|
43
|
+
# uv_os_tmpdir): TMPDIR, then TMP, then TEMP, then /tmp; strip a trailing slash.
|
|
44
|
+
# FS_GUARD_BASH_TMPDIR overrides for tests only — never set in production.
|
|
45
|
+
if [ -n "${FS_GUARD_BASH_TMPDIR:-}" ]; then
|
|
46
|
+
TMP_DIR="$FS_GUARD_BASH_TMPDIR"
|
|
47
|
+
else
|
|
48
|
+
TMP_DIR="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}"
|
|
49
|
+
TMP_DIR="${TMP_DIR%/}"
|
|
50
|
+
fi
|
|
51
|
+
SID_SAN=$(printf '%s' "$SESSION_ID" | sed 's/[^A-Za-z0-9_-]/_/g')
|
|
52
|
+
SNAP="$TMP_DIR/maxy-fsguard-snapshot-${SID_SAN}.txt"
|
|
53
|
+
|
|
54
|
+
# List the account root's immediate top-level entry names, one per line. python3
|
|
55
|
+
# os.listdir gives names only (no recursion). Any failure -> fail open.
|
|
56
|
+
ACCOUNT_DIR="$PWD" python3 -c '
|
|
57
|
+
import os, sys
|
|
58
|
+
acct = os.environ["ACCOUNT_DIR"]
|
|
59
|
+
try:
|
|
60
|
+
names = os.listdir(acct)
|
|
61
|
+
except Exception:
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
sys.stdout.write("\n".join(names))
|
|
64
|
+
' > "$SNAP" 2>/dev/null || { rm -f "$SNAP" 2>/dev/null; exit 0; }
|
|
65
|
+
|
|
66
|
+
exit 0
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
|
|
16
16
|
# the tool call cannot be inspected (tty or empty stdin). Every block logs
|
|
17
|
-
# [fs-guard] blocked path=<rel> reason=<top-level|over-deep>
|
|
17
|
+
# [fs-guard] blocked path=<rel> reason=<top-level|over-deep|bad-name>
|
|
18
18
|
# No task numbers / internal refs in any operator-visible string.
|
|
19
19
|
|
|
20
20
|
set -uo pipefail
|
|
@@ -105,4 +105,36 @@ case "$SEG0" in
|
|
|
105
105
|
;;
|
|
106
106
|
esac
|
|
107
107
|
|
|
108
|
+
# Name-convention check for the operator-data buckets, after top-level and depth
|
|
109
|
+
# pass. The operator-authored segments carry a character rule: folders are
|
|
110
|
+
# [a-z0-9-]; the write target (last segment, always a file) is lowercase
|
|
111
|
+
# dot-segments so a lowercase extension is allowed. The bucket segment (top-level
|
|
112
|
+
# rule) and dot-prefixed entries (skipped at every level system-wide) are exempt.
|
|
113
|
+
# Space-/case-/underscore-bearing names break shell paths, data-portal URL
|
|
114
|
+
# minting, and case-sensitive matching.
|
|
115
|
+
case "$SEG0" in
|
|
116
|
+
projects|contacts|documents)
|
|
117
|
+
BADSEG=$(REL="$REL" python3 -c '
|
|
118
|
+
import os, re
|
|
119
|
+
segs = os.environ["REL"].split("/")
|
|
120
|
+
folder_re = re.compile(r"^[a-z0-9-]+\Z")
|
|
121
|
+
file_re = re.compile(r"^[a-z0-9-]+(\.[a-z0-9-]+)*\Z")
|
|
122
|
+
bad = ""
|
|
123
|
+
for i, s in enumerate(segs):
|
|
124
|
+
if i == 0 or s.startswith("."):
|
|
125
|
+
continue # bucket segment (top-level rule), or a dot-prefixed entry
|
|
126
|
+
ok = file_re.match(s) if i == len(segs) - 1 else folder_re.match(s)
|
|
127
|
+
if not ok:
|
|
128
|
+
bad = s
|
|
129
|
+
break
|
|
130
|
+
print(bad)
|
|
131
|
+
' 2>/dev/null || echo "")
|
|
132
|
+
if [ -n "$BADSEG" ]; then
|
|
133
|
+
echo "[fs-guard] blocked path=$REL reason=bad-name" >&2
|
|
134
|
+
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
|
|
135
|
+
exit 2
|
|
136
|
+
fi
|
|
137
|
+
;;
|
|
138
|
+
esac
|
|
139
|
+
|
|
108
140
|
exit 0
|
|
@@ -145,7 +145,7 @@ Emit `[professional-document] render → a4-print-documents`.
|
|
|
145
145
|
|
|
146
146
|
### 8. Ask the operator for the output path
|
|
147
147
|
|
|
148
|
-
Before saving the PDF, ask the operator where they want it saved.
|
|
148
|
+
Before saving the PDF, ask the operator where they want it saved. When the document is a deliverable for a known Job (its scope of works, budget estimate, warranty, valuation, or completion certificate), propose `jobs/<jobNumber>/<file>` as the default path, where `<jobNumber>` is the job's canonical numeric id, the same key the quote path uses at `quoting/jobs/<jobId>/`. If no `jobs/<jobNumber>/` folder exists yet, it is created; `jobs/` is an ontology entity bucket, so the deliverable sits directly under the job's folder, not in `output/`. For a document tied to no Job record, ask without proposing a default. The operator can always confirm the default or name a different path, and the path the operator confirms is where the PDF is written and what the observability line records. Do not write to a path the operator has not confirmed.
|
|
149
149
|
|
|
150
150
|
### 9. Save the PDF
|
|
151
151
|
|
|
@@ -155,13 +155,13 @@ Emit `[professional-document] pdf-saved path=<operator-named-path> bytes=<n>`.
|
|
|
155
155
|
|
|
156
156
|
### 10. Deliver
|
|
157
157
|
|
|
158
|
-
The PDF you saved is the deliverable. How you hand it over depends on the surface this turn arrived on. On a message channel (web chat, WhatsApp, Telegram), call `SendUserFile` with the saved path and the platform forwards the PDF to that conversation as a real attachment. On the admin dashboard,
|
|
158
|
+
The PDF you saved is the deliverable. How you hand it over depends on the surface this turn arrived on. On a message channel (web chat, WhatsApp, Telegram), call `SendUserFile` with the saved path and the platform forwards the PDF to that conversation as a real attachment. On the admin dashboard, any saved path under `accounts/<accountId>/` (a job's `jobs/<jobNumber>/` folder, `output/`, or elsewhere in the account tree) appears in the Artefacts panel (downloadable) and is reachable on the file share. Either way, state the complete path in your reply, never a vague pointer. See the `file-presentation` skill for the wording.
|
|
159
159
|
|
|
160
160
|
## Doctrine
|
|
161
161
|
|
|
162
162
|
- **Compose, do not duplicate.** `a4-print-documents` owns the print-CSS rules; this skill loads it and follows it. `prose-craft` owns sentence-level review; this skill invokes it when present. `memory-search` owns graph reads; this skill calls it for grounding. The seam is "this skill is the workflow; the loaded skills carry the rules."
|
|
163
163
|
- **Refuse before composing.** Every brief runs through the refusal taxonomy first. A skill that drafts something it should have refused is a routing defect, not a quality issue.
|
|
164
|
-
- **Operator-named output path.**
|
|
164
|
+
- **Operator-named output path.** For a deliverable tied to a known Job, the skill proposes that job's `jobs/<jobNumber>/` entity folder as the default; for anything else it proposes no path. The operator confirms the default or names a different path, and the skill never writes to a path the operator has not confirmed. This matches how `investor-data-room` and the property composers work.
|
|
165
165
|
- **Approve the outline before drafting.** Long documents whose structure is wrong cost more to re-draft than they cost to re-outline. The approval turn is cheap; the wasted draft is expensive.
|
|
166
166
|
- **No invented facts.** Every quantitative or named claim grounds in `memory-search` or a `research-assistant` brief-back. If the graph does not carry the fact and the operator has not asked for research, draft a one-line gap statement in place ("[GAP: <description>]") and surface it in the outline-approval turn.
|
|
167
167
|
- **No live web research from this skill.** If a fact is missing, ask the admin agent to dispatch `research-assistant`. This skill does not call `WebFetch` or `WebSearch` directly.
|
|
@@ -71,16 +71,18 @@ The setup-done claim only fires when `curl -I https://<hostname>` issued from ou
|
|
|
71
71
|
|
|
72
72
|
## Data-portal folder index
|
|
73
73
|
|
|
74
|
-
The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made.
|
|
74
|
+
The portal shows a sub-account's deliverable folders alongside what the client uploaded. Which folders those are is never a list in the code: it is derived per account from that account's own `SCHEMA.md` — the buckets in its `<!-- ontology-buckets -->` region plus `output`, intersected with its `allowed-top-level` block. A brand on a different vertical exposes different folders with no code change. `bin/schema-exposed-dirs.mjs` is the only place that decision is made. An operator can add non-ontology folders (a workflow folder such as `inbound-invoices`) by listing them in `exposeFolders` in the account's `data-portal.json`; each must match `[A-Za-z0-9_-]+` and is never a system folder (`agents`, `secrets`, `specialists`, `state`, `memory`, `cache`, `logs`, `tmp`) or dot-prefixed. Those extras union on top of the schema-derived set and, unlike ontology buckets, are not intersected with `allowed-top-level`; empty or absent behaves as before.
|
|
75
75
|
|
|
76
76
|
`bin/portal-index-push.mjs` walks those folders and full-replaces the account's rows in the portal's D1 `directory` table each cycle. Metadata only — names, sizes, times — so listing survives the device being offline, and a deleted or renamed file converges on the next cycle without a tombstone. The scheduling heartbeat runs it hourly for every account carrying a `data-portal.json`; it is also runnable standalone, which is how it stays operable while the heartbeat spawn path is unproven on a device.
|
|
77
77
|
|
|
78
78
|
Opening a file reaches the install: the portal probes it, then mints an `/api/portal/fetch` link signed with the account's `PORTAL_FETCH_SECRET` and valid for five minutes. The install verifies the signature **and independently re-derives the exposed folder set**, so a correctly signed link for a non-exposed path is still refused. That second check is the security boundary, not the signature.
|
|
79
79
|
|
|
80
|
+
Exposure is narrowed twice. The install re-derives the account's exposed set from `SCHEMA.md` on every request and refuses anything outside it, which is the account-isolation boundary. On top of that, a person's `people.folders` grant (written at enrolment, empty means the full set) narrows them to named top-level folders inside that set. The grant lives only in the portal's D1, so a portal compromise widens a person back to the account's exposed set and never beyond it. `op=list` carries `granted=<n|full>` and an out-of-grant download logs `op=fetch-through result=not-granted`, distinct from `result=absent`.
|
|
81
|
+
|
|
80
82
|
What to watch, on the device:
|
|
81
83
|
|
|
82
84
|
```
|
|
83
|
-
[portal-index] op=resolve account=<id> schemaPresent=<bool> allowed=<n> domainBuckets=<dirs|none> exposed=<dirs|none>
|
|
85
|
+
[portal-index] op=resolve account=<id> schemaPresent=<bool> allowed=<n> domainBuckets=<dirs|none> extraFolders=<n> exposed=<dirs|none>
|
|
84
86
|
[portal-index] op=replace account=<id> deleted=all inserted=<n>
|
|
85
87
|
[portal-index] op=verify account=<id> rowsAfter=<n> expected=<n>
|
|
86
88
|
[portal-index] op=collision account=<id> dir=<dir> claimedBy=plugin-owned
|
|
@@ -88,7 +90,7 @@ What to watch, on the device:
|
|
|
88
90
|
[portal-index] op=audit accounts=<n> stale=<n> never-ran=<n>
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
`exposed=none` where deliverables exist means the schema was missing or carried no ontology region — not that the walk found nothing. `rowsAfter` disagreeing with `expected` is a half-applied replace. An `op=collision` line explains a folder that has silently vanished from the client's view, which nothing else would report. `op=exposed-changed` fires only on a delta, so it is rare and always worth reading; a `removed=` naming a populated bucket is the client-visible regression.
|
|
93
|
+
`exposed=none` where deliverables exist means the schema was missing or carried no ontology region — not that the walk found nothing. `rowsAfter` disagreeing with `expected` is a half-applied replace. An `op=collision` line explains a folder that has silently vanished from the client's view, which nothing else would report. `op=exposed-changed` fires only on a delta, so it is rare and always worth reading; a `removed=` naming a populated bucket is the client-visible regression. `extraFolders=<n>` counts the folders the operator requested in `exposeFolders`; a non-zero `extraFolders` with none of those names in `exposed=` means the guard dropped every one.
|
|
92
94
|
|
|
93
95
|
`op=audit` is the standing check, and it is the only one that speaks when the push does **not**. It runs in-process inside the heartbeat rather than as a spawn, and reads only local state, so it cannot be blinded by the same failure it is watching for. It fires hourly even when everything is healthy — an audit that goes quiet on success is indistinguishable from an audit that stopped running. `result=never-ran` on an account with a `data-portal.json` means no cycle has ever succeeded for it; `result=stale` with `ageMin` means cycles have stopped. Its input is written only after `op=verify`, so a stale entry means the push stopped succeeding rather than merely stopped logging.
|
|
94
96
|
|
|
@@ -71,6 +71,26 @@ if (!/^[A-Za-z0-9-]+$/.test(/** @type {string} */ (accountId))) {
|
|
|
71
71
|
die('--account must match ^[A-Za-z0-9-]+$ — an account id, not a path')
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Optional per-person folder allowlist: comma-separated top-level names that
|
|
75
|
+
// narrow what this person sees below the account's exposed set. Absent means
|
|
76
|
+
// the full set. Each name is validated to the dir-segment charset, so a grant
|
|
77
|
+
// can never carry a path separator, a comma inside a name, or a SQL quote: the
|
|
78
|
+
// value goes into the statement as a plain comma-joined literal.
|
|
79
|
+
const foldersArg = arg('--folders')
|
|
80
|
+
let folders = ''
|
|
81
|
+
if (foldersArg !== undefined) {
|
|
82
|
+
const names = foldersArg
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
for (const n of names) {
|
|
87
|
+
if (!/^[A-Za-z0-9_-]+$/.test(n)) {
|
|
88
|
+
die(`--folders entry ${JSON.stringify(n)} must match ^[A-Za-z0-9_-]+$ — a folder name, not a path`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
folders = [...new Set(names)].join(',')
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
const saltBytes = new Uint8Array(16)
|
|
75
95
|
crypto.getRandomValues(saltBytes)
|
|
76
96
|
const saltHex = randomSaltHex(saltBytes)
|
|
@@ -98,9 +118,10 @@ const createdAt = new Date().toISOString()
|
|
|
98
118
|
//
|
|
99
119
|
// `accountId` IS in the DO UPDATE: re-enrolling someone against a different
|
|
100
120
|
// sub-account has to move them, or they keep reading folders they no longer
|
|
101
|
-
// belong to.
|
|
121
|
+
// belong to. `folders` IS too, for the same reason: re-enrolment replaces the
|
|
122
|
+
// grant, so a narrower or wider set on re-run takes effect rather than sticking.
|
|
102
123
|
console.log(
|
|
103
|
-
`INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId;`,
|
|
124
|
+
`INSERT INTO people (ownerId, name, salt, hash, createdAt, accountId, folders) VALUES ('${ownerId}', '${sqlName}', '${saltHex}', '${hash}', '${createdAt}', '${accountId}', '${folders}') ON CONFLICT(ownerId) DO UPDATE SET name=excluded.name, salt=excluded.salt, hash=excluded.hash, accountId=excluded.accountId, folders=excluded.folders;`,
|
|
104
125
|
)
|
|
105
126
|
|
|
106
127
|
// stderr: everything for the human. Never stdout — see the note at the top.
|
|
@@ -42,6 +42,20 @@ function readEnvFile(path) {
|
|
|
42
42
|
return out
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** Operator-named extra folders for an account, from its data-portal.json.
|
|
46
|
+
* Absent file, absent key, or a non-array value all yield []. The resolver
|
|
47
|
+
* applies the charset-and-system guard; this reader does not pre-filter, so the
|
|
48
|
+
* `extraFolders=<n>` count reflects what the operator asked for.
|
|
49
|
+
* @param {string} accountDir @returns {Promise<string[]>} */
|
|
50
|
+
export async function readExposeFolders(accountDir) {
|
|
51
|
+
try {
|
|
52
|
+
const cfg = JSON.parse(await readFile(join(accountDir, 'data-portal.json'), 'utf8'))
|
|
53
|
+
return Array.isArray(cfg?.exposeFolders) ? cfg.exposeFolders.filter((f) => typeof f === 'string') : []
|
|
54
|
+
} catch {
|
|
55
|
+
return []
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/**
|
|
46
60
|
* Files under `exposed` dirs, at any depth. Dot-prefixed entries are skipped at
|
|
47
61
|
* every level: `.uploads-tmp/` holds in-flight upload partials (files.ts:25) and
|
|
@@ -85,7 +99,7 @@ export async function walkExposed(accountDir, exposed) {
|
|
|
85
99
|
* @param {{ accountDir: string, accountId: string, dbName: string,
|
|
86
100
|
* client: { query: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]> },
|
|
87
101
|
* log: (line: string) => void, nowIso: string,
|
|
88
|
-
* previousExposed?: string[] }} opts
|
|
102
|
+
* exposeFolders?: string[], previousExposed?: string[] }} opts
|
|
89
103
|
* @returns {Promise<{ exposed: string[], rows: number, schemaPresent: boolean }>}
|
|
90
104
|
*/
|
|
91
105
|
export async function pushAccount(opts) {
|
|
@@ -98,10 +112,15 @@ export async function pushAccount(opts) {
|
|
|
98
112
|
schemaText = null
|
|
99
113
|
}
|
|
100
114
|
|
|
101
|
-
const
|
|
115
|
+
const exposeFolders = Array.isArray(opts.exposeFolders) ? opts.exposeFolders : []
|
|
116
|
+
const r = resolveExposedDirs(schemaText, exposeFolders)
|
|
117
|
+
// extraFolders is the REQUESTED count, not the accepted count: a non-zero
|
|
118
|
+
// extraFolders with none of those names in exposed= means the guard dropped
|
|
119
|
+
// every one, which is distinct from none configured (extraFolders=0).
|
|
102
120
|
log(
|
|
103
121
|
`${TAG} op=resolve account=${accountId} schemaPresent=${r.schemaPresent} allowed=${r.allowed.length} ` +
|
|
104
|
-
`domainBuckets=${r.domainBuckets.join(',') || 'none'}
|
|
122
|
+
`domainBuckets=${r.domainBuckets.join(',') || 'none'} extraFolders=${exposeFolders.length} ` +
|
|
123
|
+
`exposed=${r.exposed.join(',') || 'none'}`,
|
|
105
124
|
)
|
|
106
125
|
// The generator gives a plugin-claimed dir to the plugin-owned region only
|
|
107
126
|
// (account-schema-owned-dirs.py:287), so this is normally silent. When it is
|
|
@@ -212,10 +231,10 @@ function arg(flag) {
|
|
|
212
231
|
* Every account carrying a data-portal.json has a portal. The file's presence is
|
|
213
232
|
* the enrolment signal — there is no separate flag to keep in sync with it.
|
|
214
233
|
* @param {string} accountsRoot
|
|
215
|
-
* @returns {Promise<{accountDir: string, accountId: string, dbName: string}[]>}
|
|
234
|
+
* @returns {Promise<{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]>}
|
|
216
235
|
*/
|
|
217
236
|
async function portalAccounts(accountsRoot) {
|
|
218
|
-
/** @type {{accountDir: string, accountId: string, dbName: string}[]} */ const out = []
|
|
237
|
+
/** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ const out = []
|
|
219
238
|
/** @type {string[]} */ let names
|
|
220
239
|
try {
|
|
221
240
|
names = await readdir(accountsRoot)
|
|
@@ -227,7 +246,10 @@ async function portalAccounts(accountsRoot) {
|
|
|
227
246
|
try {
|
|
228
247
|
const cfg = JSON.parse(await readFile(join(accountDir, 'data-portal.json'), 'utf8'))
|
|
229
248
|
if (typeof cfg?.portalDbName === 'string' && cfg.portalDbName) {
|
|
230
|
-
|
|
249
|
+
const exposeFolders = Array.isArray(cfg?.exposeFolders)
|
|
250
|
+
? cfg.exposeFolders.filter((f) => typeof f === 'string')
|
|
251
|
+
: []
|
|
252
|
+
out.push({ accountDir, accountId, dbName: cfg.portalDbName, exposeFolders })
|
|
231
253
|
} else {
|
|
232
254
|
console.error(`${TAG} op=skip account=${accountId} reason=no-portalDbName`)
|
|
233
255
|
}
|
|
@@ -292,7 +314,7 @@ async function main() {
|
|
|
292
314
|
process.exit(1)
|
|
293
315
|
}
|
|
294
316
|
|
|
295
|
-
/** @type {{accountDir: string, accountId: string, dbName: string}[]} */ let targets
|
|
317
|
+
/** @type {{accountDir: string, accountId: string, dbName: string, exposeFolders: string[]}[]} */ let targets
|
|
296
318
|
if (process.argv.includes('--all-accounts')) {
|
|
297
319
|
// Same derivation as the platform's own DATA_ROOT (data-path.ts:19), from
|
|
298
320
|
// the PLATFORM_ROOT the heartbeat already passes.
|
|
@@ -307,7 +329,7 @@ async function main() {
|
|
|
307
329
|
console.error(`${TAG} op=usage --account-dir <dir> --account <id> --db <name>`)
|
|
308
330
|
process.exit(1)
|
|
309
331
|
}
|
|
310
|
-
targets = [{ accountDir, accountId, dbName }]
|
|
332
|
+
targets = [{ accountDir, accountId, dbName, exposeFolders: await readExposeFolders(accountDir) }]
|
|
311
333
|
}
|
|
312
334
|
|
|
313
335
|
const state = readState(platformRoot)
|
|
@@ -21,6 +21,18 @@ const OWNED_END = '<!-- plugin-owned-dirs:end -->'
|
|
|
21
21
|
/** The delivery convention, always exposed when the account allows it. */
|
|
22
22
|
export const ALWAYS_EXPOSED = 'output'
|
|
23
23
|
|
|
24
|
+
/** Folders that must never be named into exposure, even by an operator. The
|
|
25
|
+
* dot-prefixed `.git`/`.claude` are already excluded by the charset guard
|
|
26
|
+
* below. This is the not-system half of the extra-folder bound. */
|
|
27
|
+
const SYSTEM_DIRS = new Set(['agents', 'secrets', 'specialists', 'state', 'memory', 'cache', 'logs', 'tmp'])
|
|
28
|
+
|
|
29
|
+
/** An operator-named extra folder is accepted only as a single safe segment
|
|
30
|
+
* (the same charset the per-person `--folders` grant enforces) and never a
|
|
31
|
+
* system dir. This drops dot-prefixed names, slashes, and traversal such as
|
|
32
|
+
* `jobs/../secrets`, which would otherwise let the push walk index files under
|
|
33
|
+
* a system dir that the fetch route would refuse to serve. */
|
|
34
|
+
const SAFE_FOLDER = /^[A-Za-z0-9_-]+$/
|
|
35
|
+
|
|
24
36
|
/**
|
|
25
37
|
* The generator writes the ontology region with an ASCII hyphen and the
|
|
26
38
|
* plugin-owned region with an em dash (account-schema-owned-dirs.py:294 vs
|
|
@@ -53,10 +65,11 @@ function allowedBlockOrNull(text) {
|
|
|
53
65
|
|
|
54
66
|
/**
|
|
55
67
|
* @param {string | null} schemaText Contents of accounts/<id>/SCHEMA.md, or null when absent.
|
|
68
|
+
* @param {string[]} [exposeFolders] Operator-named extra top-level folders. Empty or absent = today.
|
|
56
69
|
* @returns {{ schemaPresent: boolean, allowed: string[], domainBuckets: string[],
|
|
57
70
|
* pluginOwned: string[], collisions: string[], exposed: string[] }}
|
|
58
71
|
*/
|
|
59
|
-
export function resolveExposedDirs(schemaText) {
|
|
72
|
+
export function resolveExposedDirs(schemaText, exposeFolders = []) {
|
|
60
73
|
const empty = {
|
|
61
74
|
schemaPresent: false,
|
|
62
75
|
allowed: /** @type {string[]} */ ([]),
|
|
@@ -97,7 +110,14 @@ export function resolveExposedDirs(schemaText) {
|
|
|
97
110
|
const collisions = domainBuckets.filter((d) => pluginOwned.includes(d))
|
|
98
111
|
|
|
99
112
|
const candidates = [ALWAYS_EXPOSED, ...domainBuckets.filter((d) => !collisions.includes(d))]
|
|
100
|
-
const
|
|
113
|
+
const ontologyExposed = [...new Set(candidates)].filter((d) => allowed.includes(d))
|
|
114
|
+
// Extras are unioned AFTER the ontology step and are deliberately NOT
|
|
115
|
+
// intersected with `allowed`: a workflow folder is legitimately outside the
|
|
116
|
+
// ontology set. The charset-plus-deny guard is the whole bound.
|
|
117
|
+
const extras = (Array.isArray(exposeFolders) ? exposeFolders : []).filter(
|
|
118
|
+
(f) => typeof f === 'string' && SAFE_FOLDER.test(f) && !SYSTEM_DIRS.has(f),
|
|
119
|
+
)
|
|
120
|
+
const exposed = [...new Set([...ontologyExposed, ...extras])].sort()
|
|
101
121
|
|
|
102
122
|
return { schemaPresent: true, allowed, domainBuckets, pluginOwned, collisions, exposed }
|
|
103
123
|
}
|
|
@@ -67,7 +67,7 @@ describe('sessions', () => {
|
|
|
67
67
|
it('mints a session resolvable to its owner', async () => {
|
|
68
68
|
const { db } = fakeDb()
|
|
69
69
|
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
70
|
-
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '' })
|
|
70
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] })
|
|
71
71
|
})
|
|
72
72
|
|
|
73
73
|
it('does not resolve an expired session', async () => {
|
|
@@ -97,7 +97,7 @@ describe('session revocation — the property the design claims', () => {
|
|
|
97
97
|
// by construction, not by remembering to delete anything.
|
|
98
98
|
const { db, peopleRows } = fakeDb()
|
|
99
99
|
await mintSession(db, 'alice', 'sess-1', 1_000_000, PC)
|
|
100
|
-
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '' })
|
|
100
|
+
expect(await resolveSession(db, 'sess-1', 1_000_000)).toEqual({ ownerId: 'alice', accountId: '', folders: [] })
|
|
101
101
|
|
|
102
102
|
peopleRows[0].hash = 'HASH-v2-rotated0' // operator re-runs enrolment
|
|
103
103
|
|
|
@@ -9,7 +9,9 @@ const DIRECTORY = [
|
|
|
9
9
|
{ accountId: 'acc-b', relPath: 'output/secret.pdf', sizeBytes: 40, modifiedAt: '2026-07-20T13:00:00.000Z' },
|
|
10
10
|
]
|
|
11
11
|
|
|
12
|
-
function makeEnv(
|
|
12
|
+
function makeEnv(
|
|
13
|
+
sessions: Record<string, { ownerId: string; accountId: string; folders?: string }>,
|
|
14
|
+
) {
|
|
13
15
|
return {
|
|
14
16
|
DB: {
|
|
15
17
|
prepare(query: string) {
|
|
@@ -39,7 +41,7 @@ function makeEnv(sessions: Record<string, { ownerId: string; accountId: string }
|
|
|
39
41
|
} as unknown as PortalEnv
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a' } }
|
|
44
|
+
const SESSIONS = { 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } }
|
|
43
45
|
type Entry = { name: string; kind: string; relPath: string; sizeBytes: number | null; modifiedAt: string | null }
|
|
44
46
|
|
|
45
47
|
describe('entriesUnder', () => {
|
|
@@ -117,4 +119,49 @@ describe('processFiles directory listing', () => {
|
|
|
117
119
|
expect(r.payload).toHaveProperty('files')
|
|
118
120
|
expect(r.payload).toHaveProperty('entries')
|
|
119
121
|
})
|
|
122
|
+
|
|
123
|
+
it('shows only granted top-level folders', async () => {
|
|
124
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
|
|
125
|
+
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
126
|
+
expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['quotes'])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('an empty grant shows the full set', async () => {
|
|
130
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } })
|
|
131
|
+
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
132
|
+
expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['output', 'quotes'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('a grant naming an absent folder shows nothing', async () => {
|
|
136
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'jobs' } })
|
|
137
|
+
const r = await processFiles('sess-a', env, () => {}, 0, '')
|
|
138
|
+
expect(r.payload.entries).toEqual([])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('the grant holds when browsing into a granted folder', async () => {
|
|
142
|
+
const env = makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } })
|
|
143
|
+
const r = await processFiles('sess-a', env, () => {}, 0, 'quotes')
|
|
144
|
+
expect((r.payload.entries as Entry[]).map((e) => e.name)).toEqual(['CR2969', 'CR2974'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('logs granted=1 for a one-folder grant and granted=full for an empty grant', async () => {
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
await processFiles(
|
|
150
|
+
'sess-a',
|
|
151
|
+
makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: 'quotes' } }),
|
|
152
|
+
(l) => lines.push(l),
|
|
153
|
+
0,
|
|
154
|
+
'',
|
|
155
|
+
)
|
|
156
|
+
expect(lines.some((l) => l.includes('granted=1'))).toBe(true)
|
|
157
|
+
const full: string[] = []
|
|
158
|
+
await processFiles(
|
|
159
|
+
'sess-a',
|
|
160
|
+
makeEnv({ 'sess-a': { ownerId: 'alice', accountId: 'acc-a', folders: '' } }),
|
|
161
|
+
(l) => full.push(l),
|
|
162
|
+
0,
|
|
163
|
+
'',
|
|
164
|
+
)
|
|
165
|
+
expect(full.some((l) => l.includes('granted=full'))).toBe(true)
|
|
166
|
+
})
|
|
120
167
|
})
|