@rubytech/create-maxy-code 0.1.349 → 0.1.350
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-06-23-account-filesystem-schema.md +544 -0
- package/payload/platform/docs/superpowers/specs/2026-06-23-account-filesystem-schema-design.md +170 -0
- package/payload/platform/plugins/admin/hooks/__tests__/fs-schema-guard.test.sh +68 -0
- package/payload/platform/plugins/admin/hooks/fs-schema-guard.sh +108 -0
- package/payload/platform/scripts/lib/provision-account-dir.sh +22 -3
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +45 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +11 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +18 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/system-prompt.d.ts +1 -0
- 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 +16 -0
- package/payload/platform/services/claude-session-manager/dist/system-prompt.js.map +1 -1
- package/payload/platform/templates/account-schema/SCHEMA.md +54 -0
- package/payload/server/public/assets/data-CBrgiyPM.js +1 -0
- package/payload/server/public/data.html +1 -1
- package/payload/server/server.js +60 -16
- package/payload/server/public/assets/data-CH6GNBO3.js +0 -1
package/payload/platform/docs/superpowers/specs/2026-06-23-account-filesystem-schema-design.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Account filesystem schema: seed it, and enforce file↔graph stewardship — design
|
|
2
|
+
|
|
3
|
+
Source task: `.tasks/1091-account-filesystem-schema-enforcement.md`. Backstop
|
|
4
|
+
(out of scope here): `.tasks/1092-account-dir-standing-reconcile-and-quarantine.md`.
|
|
5
|
+
|
|
6
|
+
## Problem (recap)
|
|
7
|
+
|
|
8
|
+
The account data dir (`data/accounts/<id>/`) should be a consistent projection of
|
|
9
|
+
the graph ontology, but that doctrine lives only inside the `data-manager`
|
|
10
|
+
specialist the main agent often routes around, nothing seeds a canonical file
|
|
11
|
+
layout, and nothing stops an agent inventing folders. The observed failure: an
|
|
12
|
+
agent invented `projects/<name>/deploy|sources|design|archive` with no ontology
|
|
13
|
+
basis, justified as "there was no canonical schema to copy from."
|
|
14
|
+
|
|
15
|
+
## Resolved gating decision
|
|
16
|
+
|
|
17
|
+
`provision_account_dir` runs **unprivileged** on the Pi target — at install and
|
|
18
|
+
at runtime when the `account_create` admin tool calls it via `execFileSync`
|
|
19
|
+
inside the `systemctl --user` brand service. `chattr +i` needs root
|
|
20
|
+
(`CAP_LINUX_IMMUTABLE`). So the task's Decision C1 (OS-immutable top level)
|
|
21
|
+
**cannot take effect on the real target**.
|
|
22
|
+
|
|
23
|
+
**Decision (operator, 2026-06-23): drop C1 entirely.** No `chattr`/`chflags`
|
|
24
|
+
code ships. The PreToolUse Write-hook becomes the sole tool-layer hard guard;
|
|
25
|
+
top-level strays created via bash fall to the Task 1092 reconcile. This also
|
|
26
|
+
removes the chattr observability line and the upgrade/uninstall-unlock risk from
|
|
27
|
+
the task.
|
|
28
|
+
|
|
29
|
+
## What ships
|
|
30
|
+
|
|
31
|
+
Three layers, honest about hard-enforceable vs cooperative.
|
|
32
|
+
|
|
33
|
+
### A. Seeded schema (one standard, every account)
|
|
34
|
+
|
|
35
|
+
`provision_account_dir` (the function both `setup-account.sh` and the
|
|
36
|
+
`account_create` lifecycle tool call) gains an idempotent seed step:
|
|
37
|
+
|
|
38
|
+
- **Create the operator-data skeleton:** `projects/`, `contacts/`, `documents/`.
|
|
39
|
+
- **Create the tool-owned skeleton:** `url-get/`, `output/`, `generated/`,
|
|
40
|
+
`extracted/`, `uploads/` (`agents/`, `specialists/` already created earlier in
|
|
41
|
+
the function).
|
|
42
|
+
- **Copy `SCHEMA.md`** to the account root from a shipped template
|
|
43
|
+
(`platform/templates/account-schema/SCHEMA.md`). The template is the **single
|
|
44
|
+
source of truth** for the allowed top-level set (see below) and the flatness
|
|
45
|
+
rule.
|
|
46
|
+
- **Log** `[acct-schema] seeded dirs=<n>` once per provision. Idempotent: a
|
|
47
|
+
re-provision over an existing account re-creates nothing and overwrites
|
|
48
|
+
`SCHEMA.md` with the shipped version (Rubytech-controlled, like `IDENTITY.md`).
|
|
49
|
+
|
|
50
|
+
Operator-data buckets are **flat**: files live directly under one entity folder
|
|
51
|
+
(`projects/<name>/file`, `contacts/<name>/file`) or, for `documents/`, directly
|
|
52
|
+
in the bucket or one document-folder deep. Flatness is what makes
|
|
53
|
+
`projects/<name>/deploy/` a machine-detectable violation.
|
|
54
|
+
|
|
55
|
+
### B. `DATA_STEWARDSHIP` directive (cooperative layer)
|
|
56
|
+
|
|
57
|
+
A `DATA_STEWARDSHIP` const beside `PLATFORM_BOUNDARY` in
|
|
58
|
+
`platform/services/claude-session-manager/src/system-prompt.ts`, rendered as a
|
|
59
|
+
`<data-stewardship>` section in `renderAppendBlock` next to `<platform-boundary>`
|
|
60
|
+
— joined into `--append-system-prompt` on **every spawn, every role**. It states:
|
|
61
|
+
the account-dir layout is fixed by the seeded `SCHEMA.md`; never author new
|
|
62
|
+
top-level folders or nested subtrees under an entity folder; route
|
|
63
|
+
reorganisation/moves through `data-manager` and non-trivial graph writes through
|
|
64
|
+
`database-operator`; the existing carve-out (a one-line update against an
|
|
65
|
+
in-context, unambiguously-classified node) stays direct.
|
|
66
|
+
|
|
67
|
+
`runSystemPromptSelfTest` is extended to assert `<data-stewardship>` /
|
|
68
|
+
`</data-stewardship>` appear in the rendered block.
|
|
69
|
+
|
|
70
|
+
### C. PreToolUse Write-hook (hard guard, no bash removal)
|
|
71
|
+
|
|
72
|
+
A new hook `platform/plugins/admin/hooks/fs-schema-guard.sh`, seeded into the
|
|
73
|
+
account `.claude/settings.json` as a PreToolUse hook on `Write`, `Edit`, and
|
|
74
|
+
`NotebookEdit` (added alongside the existing `archive-ingest-surface-gate.sh`
|
|
75
|
+
entries on those three matchers).
|
|
76
|
+
|
|
77
|
+
Behaviour:
|
|
78
|
+
|
|
79
|
+
1. Read the stdin JSON envelope. Fail **closed** on terminal stdin (no envelope).
|
|
80
|
+
2. Extract the target path: `tool_input.file_path` for Write/Edit,
|
|
81
|
+
`tool_input.notebook_path` for NotebookEdit (via python3, matching the
|
|
82
|
+
existing `extract_tool_input_field` pattern — never jq, never command parsing).
|
|
83
|
+
3. Resolve the path against the account dir. If the resolved path is **outside**
|
|
84
|
+
the account data dir, **allow** (exit 0) — this hook governs the account-dir
|
|
85
|
+
schema only; platform-source writes are PLATFORM_BOUNDARY's concern.
|
|
86
|
+
4. Compute the path relative to the account dir. Let `seg0` be its first segment.
|
|
87
|
+
5. **Top-level check:** if `seg0` is a single path segment (a top-level file or
|
|
88
|
+
dir) not in the allowed top-level set → **block** (exit 2),
|
|
89
|
+
`reason=top-level`.
|
|
90
|
+
6. **Over-deep check:** if `seg0 ∈ {projects, contacts}` and the path has more
|
|
91
|
+
than `bucket/entity/file` depth (i.e. a directory component beyond the single
|
|
92
|
+
entity folder), or `seg0 == documents` and the path exceeds
|
|
93
|
+
`documents/folder/file` → **block** (exit 2), `reason=over-deep`.
|
|
94
|
+
7. Tool-owned dirs (everything in the allowed set except the three operator-data
|
|
95
|
+
buckets) pass at **any** depth.
|
|
96
|
+
8. Otherwise allow (exit 0).
|
|
97
|
+
9. Every block logs `[fs-guard] blocked path=<rel> reason=<top-level|over-deep>`
|
|
98
|
+
to stderr (operator-greppable). No task numbers / internal refs in any
|
|
99
|
+
operator-visible string. The exit-2 stderr message cites the schema in plain
|
|
100
|
+
terms.
|
|
101
|
+
|
|
102
|
+
**Allowed top-level set — single source.** The set lives once, as a fenced
|
|
103
|
+
` ```allowed-top-level ` block in `platform/templates/account-schema/SCHEMA.md`
|
|
104
|
+
(newline-delimited). The hook parses that fenced block from the **account's**
|
|
105
|
+
seeded `SCHEMA.md` at runtime (deterministic structured data, not prose control
|
|
106
|
+
flow). Shipped contents:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
projects
|
|
110
|
+
contacts
|
|
111
|
+
documents
|
|
112
|
+
url-get
|
|
113
|
+
output
|
|
114
|
+
generated
|
|
115
|
+
extracted
|
|
116
|
+
uploads
|
|
117
|
+
agents
|
|
118
|
+
specialists
|
|
119
|
+
sites
|
|
120
|
+
public
|
|
121
|
+
cache
|
|
122
|
+
secrets
|
|
123
|
+
state
|
|
124
|
+
logs
|
|
125
|
+
tmp
|
|
126
|
+
SCHEMA.md
|
|
127
|
+
account.json
|
|
128
|
+
AGENTS.md
|
|
129
|
+
.claude
|
|
130
|
+
.git
|
|
131
|
+
.quarantine
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A unit test asserts the hook's parse of the template equals this documented set,
|
|
135
|
+
so the human doc and the enforcement never drift. Adding a future plugin
|
|
136
|
+
top-level dir is a one-line edit to this block.
|
|
137
|
+
|
|
138
|
+
## Out of scope (unchanged from task)
|
|
139
|
+
|
|
140
|
+
- The standing reconcile / quarantine — Task 1092 (named as the backstop here,
|
|
141
|
+
not built).
|
|
142
|
+
- Removing or narrowing the main agent's `Bash`/`Write`/`Edit` tools — rejected.
|
|
143
|
+
- Auto-repairing drift — `data-manager` fixes on dispatch.
|
|
144
|
+
- Per-account schema variants.
|
|
145
|
+
- Any change to the graph schema or the `database-operator` write path.
|
|
146
|
+
- `chattr`/`chflags` OS-immutability (Decision C1) — dropped per the gating
|
|
147
|
+
decision above.
|
|
148
|
+
|
|
149
|
+
## Verification
|
|
150
|
+
|
|
151
|
+
- **Seed:** a fresh `provision_account_dir` run produces the documented skeleton
|
|
152
|
+
and `SCHEMA.md`; the seeded allowed-top-level set matches the template exactly.
|
|
153
|
+
Re-provision is idempotent.
|
|
154
|
+
- **Write-hook:** a `Write` to `projects/Acme/deploy/sources/a.txt` blocks
|
|
155
|
+
(`over-deep`); `projects/Acme/a.txt` and `documents/a.pdf` allow; a write into
|
|
156
|
+
`output/` or `generated/` at any depth allows; a write to a novel top-level dir
|
|
157
|
+
`whim/x` blocks (`top-level`); a write outside the account dir allows.
|
|
158
|
+
- **Directive:** `runSystemPromptSelfTest` asserts the `<data-stewardship>` tag.
|
|
159
|
+
- **No tool regression:** the main agent retains `Bash`/`Write`/`Edit`; existing
|
|
160
|
+
hook + spawn + specialist tests pass; `platform/ui` (if touched) builds/lints
|
|
161
|
+
clean. The new hook does not interfere with the existing PreToolUse gate on the
|
|
162
|
+
same matchers (both run; either can block).
|
|
163
|
+
- **Device:** after publish + upgrade, a real account shows the seeded skeleton,
|
|
164
|
+
`SCHEMA.md`, and a blocked whim-write in the logs.
|
|
165
|
+
|
|
166
|
+
## Observability
|
|
167
|
+
|
|
168
|
+
- `provision_account_dir` logs `[acct-schema] seeded dirs=<n>`.
|
|
169
|
+
- The Write-hook logs `[fs-guard] blocked path=<rel> reason=<top-level|over-deep>`
|
|
170
|
+
per block.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Regression test for fs-schema-guard.sh.
|
|
3
|
+
#
|
|
4
|
+
# Covers:
|
|
5
|
+
# 1. Non-Write/Edit/NotebookEdit tool -> ALLOW (early exit)
|
|
6
|
+
# 2. Write to projects/Acme/a.txt (flat) -> ALLOW
|
|
7
|
+
# 3. Write to documents/a.pdf (loose) -> ALLOW
|
|
8
|
+
# 4. Write to output/deep/nested/x.png (tool-owned) -> ALLOW (any depth)
|
|
9
|
+
# 5. Write to projects/Acme/deploy/sources/a.txt -> BLOCK over-deep
|
|
10
|
+
# 6. Write to projects/Acme/deploy/a.txt -> BLOCK over-deep
|
|
11
|
+
# 7. Write to whim/x.txt (novel top-level) -> BLOCK top-level
|
|
12
|
+
# 8. Write to an absolute path outside the account dir -> ALLOW (not our concern)
|
|
13
|
+
# 9. NotebookEdit over-deep (notebook_path) -> BLOCK over-deep
|
|
14
|
+
# 10. Empty stdin (cannot inspect) -> BLOCK fail-closed
|
|
15
|
+
# 11. Allowed-set parse matches the documented set -> assertion
|
|
16
|
+
set -u
|
|
17
|
+
|
|
18
|
+
HOOK="$(cd "$(dirname "$0")/.." && pwd)/fs-schema-guard.sh"
|
|
19
|
+
[ -x "$HOOK" ] || { echo "FAIL: $HOOK not executable" >&2; exit 1; }
|
|
20
|
+
|
|
21
|
+
# A scratch account dir with a seeded SCHEMA.md (copied from the shipped template).
|
|
22
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
|
|
23
|
+
TEMPLATE="$REPO_ROOT/templates/account-schema/SCHEMA.md"
|
|
24
|
+
[ -f "$TEMPLATE" ] || { echo "FAIL: template missing at $TEMPLATE" >&2; exit 1; }
|
|
25
|
+
ACCT=$(mktemp -d)
|
|
26
|
+
trap 'rm -rf "$ACCT"' EXIT
|
|
27
|
+
cp "$TEMPLATE" "$ACCT/SCHEMA.md"
|
|
28
|
+
mkdir -p "$ACCT/projects/Acme" "$ACCT/documents" "$ACCT/output"
|
|
29
|
+
|
|
30
|
+
PASS=0; FAIL=0
|
|
31
|
+
# Run one case in the account dir as cwd.
|
|
32
|
+
# $1 name $2 stdin $3 expected_exit $4 expected_log_re ("" = none)
|
|
33
|
+
run_case() {
|
|
34
|
+
local name="$1" stdin="$2" exp="$3" re="$4" ef actual
|
|
35
|
+
ef=$(mktemp)
|
|
36
|
+
( cd "$ACCT" && printf '%s' "$stdin" | bash "$HOOK" >/dev/null 2>"$ef" )
|
|
37
|
+
actual=$?
|
|
38
|
+
local sc; sc=$(cat "$ef"); rm -f "$ef"
|
|
39
|
+
local ok=1
|
|
40
|
+
[ "$actual" -eq "$exp" ] || ok=0
|
|
41
|
+
if [ -n "$re" ] && ! printf '%s' "$sc" | grep -Eq "$re"; then ok=0; fi
|
|
42
|
+
if [ $ok -eq 1 ]; then echo "PASS: $name (exit=$actual)"; PASS=$((PASS+1));
|
|
43
|
+
else echo "FAIL: $name (exit=$actual, want $exp; log want /$re/)" >&2; FAIL=$((FAIL+1)); fi
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
mkenv() { printf '{"hook_event_name":"PreToolUse","tool_name":"%s","tool_input":{"%s":"%s"}}' "$1" "$2" "$3"; }
|
|
47
|
+
|
|
48
|
+
run_case "non-write allow" "$(mkenv Bash command 'ls')" 0 ""
|
|
49
|
+
run_case "flat project allow" "$(mkenv Write file_path 'projects/Acme/a.txt')" 0 ""
|
|
50
|
+
run_case "loose document allow" "$(mkenv Write file_path 'documents/a.pdf')" 0 ""
|
|
51
|
+
run_case "tool-owned deep allow" "$(mkenv Write file_path 'output/deep/nested/x.png')" 0 ""
|
|
52
|
+
run_case "over-deep block" "$(mkenv Write file_path 'projects/Acme/deploy/sources/a.txt')" 2 "fs-guard. blocked path=projects/Acme/deploy/sources/a.txt reason=over-deep"
|
|
53
|
+
run_case "over-deep one block" "$(mkenv Write file_path 'projects/Acme/deploy/a.txt')" 2 "reason=over-deep"
|
|
54
|
+
run_case "top-level block" "$(mkenv Write file_path 'whim/x.txt')" 2 "fs-guard. blocked path=whim/x.txt reason=top-level"
|
|
55
|
+
run_case "outside acct allow" "$(mkenv Write file_path '/etc/passwd')" 0 ""
|
|
56
|
+
run_case "notebook over-deep" "$(mkenv NotebookEdit notebook_path 'projects/Acme/deploy/n.ipynb')" 2 "reason=over-deep"
|
|
57
|
+
run_case "empty stdin block" "" 2 "no stdin"
|
|
58
|
+
# JSON null file_path must coerce to empty (allow), not the literal string "None".
|
|
59
|
+
run_case "null path allow" '{"hook_event_name":"PreToolUse","tool_name":"Write","tool_input":{"file_path":null}}' 0 ""
|
|
60
|
+
|
|
61
|
+
# Allowed-set parse == documented set.
|
|
62
|
+
EXPECT="projects contacts documents url-get output generated extracted uploads agents specialists sites public cache secrets state logs tmp SCHEMA.md account.json AGENTS.md .claude .git"
|
|
63
|
+
GOT=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCT/SCHEMA.md" | tr '\n' ' ' | sed 's/ *$//')
|
|
64
|
+
if [ "$GOT" = "$EXPECT" ]; then echo "PASS: allowed-set parse"; PASS=$((PASS+1));
|
|
65
|
+
else echo "FAIL: allowed-set parse: got [$GOT]" >&2; FAIL=$((FAIL+1)); fi
|
|
66
|
+
|
|
67
|
+
echo "----- $PASS passed, $FAIL failed -----"
|
|
68
|
+
[ $FAIL -eq 0 ]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# fs-schema-guard — PreToolUse Write/Edit/NotebookEdit path guard.
|
|
3
|
+
#
|
|
4
|
+
# Enforces the seeded account-dir file schema (see <accountDir>/SCHEMA.md):
|
|
5
|
+
# - the target's first path segment must be in the allowed top-level set
|
|
6
|
+
# (parsed from the fenced ```allowed-top-level block of the account's
|
|
7
|
+
# SCHEMA.md — our own structured data, not CLI prose);
|
|
8
|
+
# - a target under an operator-data bucket (projects/ contacts/) may be at
|
|
9
|
+
# most <bucket>/<entity>/<file> deep; documents/ may be at most
|
|
10
|
+
# <bucket>/<folder>/<file> deep. Tool-owned dirs pass at any depth.
|
|
11
|
+
#
|
|
12
|
+
# The hook governs the account dir only. A path resolving outside the account
|
|
13
|
+
# dir (cwd) is allowed — platform-source writes are PLATFORM_BOUNDARY's concern.
|
|
14
|
+
#
|
|
15
|
+
# Exit codes: 0 = allow, 2 = block (stderr shown to the agent). Fail closed when
|
|
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>
|
|
18
|
+
# No task numbers / internal refs in any operator-visible string.
|
|
19
|
+
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
|
|
22
|
+
# Fail closed if attached to a terminal (no JSON envelope coming).
|
|
23
|
+
if [ -t 0 ]; then
|
|
24
|
+
echo "Blocked: fs-schema-guard received no stdin (cannot inspect the write). Failing closed." >&2
|
|
25
|
+
exit 2
|
|
26
|
+
fi
|
|
27
|
+
INPUT=$(cat)
|
|
28
|
+
if [ -z "$INPUT" ]; then
|
|
29
|
+
echo "Blocked: fs-schema-guard received no stdin (cannot inspect the write). Failing closed." >&2
|
|
30
|
+
exit 2
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
field() {
|
|
34
|
+
printf '%s' "$INPUT" | python3 -c "
|
|
35
|
+
import sys, json
|
|
36
|
+
try:
|
|
37
|
+
d = json.load(sys.stdin)
|
|
38
|
+
v = (d.get('$1', {}) or {}).get('$2', '') if '$1' else d.get('$2', '')
|
|
39
|
+
print(v if isinstance(v, str) else '')
|
|
40
|
+
except Exception:
|
|
41
|
+
print('')
|
|
42
|
+
" 2>/dev/null || echo ""
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
TOOL_NAME=$(field '' tool_name)
|
|
46
|
+
case "$TOOL_NAME" in
|
|
47
|
+
Write|Edit) FILE_PATH=$(field tool_input file_path) ;;
|
|
48
|
+
NotebookEdit) FILE_PATH=$(field tool_input notebook_path) ;;
|
|
49
|
+
*) exit 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
[ -z "$FILE_PATH" ] && exit 0
|
|
52
|
+
|
|
53
|
+
ACCOUNT_DIR="$PWD"
|
|
54
|
+
|
|
55
|
+
# Resolve to an account-relative path; allow anything outside the account dir.
|
|
56
|
+
REL=$(FILE_PATH="$FILE_PATH" ACCOUNT_DIR="$ACCOUNT_DIR" python3 -c '
|
|
57
|
+
import os, sys
|
|
58
|
+
fp = os.environ["FILE_PATH"]; acct = os.environ["ACCOUNT_DIR"]
|
|
59
|
+
ab = fp if os.path.isabs(fp) else os.path.join(acct, fp)
|
|
60
|
+
ab = os.path.normpath(ab); acct = os.path.normpath(acct)
|
|
61
|
+
if ab == acct or not ab.startswith(acct + os.sep):
|
|
62
|
+
print("") # outside the account dir (or the dir itself)
|
|
63
|
+
else:
|
|
64
|
+
print(os.path.relpath(ab, acct))
|
|
65
|
+
' 2>/dev/null || echo "")
|
|
66
|
+
[ -z "$REL" ] && exit 0
|
|
67
|
+
|
|
68
|
+
# Parse the allowed top-level set from the account's seeded SCHEMA.md.
|
|
69
|
+
ALLOWED=""
|
|
70
|
+
if [ -f "$ACCOUNT_DIR/SCHEMA.md" ]; then
|
|
71
|
+
ALLOWED=$(awk '/^```allowed-top-level$/{f=1;next} /^```$/{f=0} f' "$ACCOUNT_DIR/SCHEMA.md")
|
|
72
|
+
fi
|
|
73
|
+
# Fail open on a missing/empty schema (an un-seeded legacy account must not have
|
|
74
|
+
# every write blocked). A re-provision seeds SCHEMA.md, after which the guard
|
|
75
|
+
# applies.
|
|
76
|
+
[ -z "$ALLOWED" ] && exit 0
|
|
77
|
+
|
|
78
|
+
SEG0=${REL%%/*}
|
|
79
|
+
|
|
80
|
+
# Top-level check.
|
|
81
|
+
if ! printf '%s\n' "$ALLOWED" | grep -qxF "$SEG0"; then
|
|
82
|
+
echo "[fs-guard] blocked path=$REL reason=top-level" >&2
|
|
83
|
+
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
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Over-deep check for operator-data buckets. Count path segments.
|
|
88
|
+
depth=$(printf '%s' "$REL" | awk -F/ '{print NF}')
|
|
89
|
+
case "$SEG0" in
|
|
90
|
+
projects|contacts)
|
|
91
|
+
# Allowed: <bucket>/<entity>/<file> = 3 segments max; <bucket>/<file> = 2 ok.
|
|
92
|
+
if [ "$depth" -gt 3 ]; then
|
|
93
|
+
echo "[fs-guard] blocked path=$REL reason=over-deep" >&2
|
|
94
|
+
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
|
|
95
|
+
exit 2
|
|
96
|
+
fi
|
|
97
|
+
;;
|
|
98
|
+
documents)
|
|
99
|
+
# Allowed: documents/<file> = 2, documents/<folder>/<file> = 3 max.
|
|
100
|
+
if [ "$depth" -gt 3 ]; then
|
|
101
|
+
echo "[fs-guard] blocked path=$REL reason=over-deep" >&2
|
|
102
|
+
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
|
|
103
|
+
exit 2
|
|
104
|
+
fi
|
|
105
|
+
;;
|
|
106
|
+
esac
|
|
107
|
+
|
|
108
|
+
exit 0
|
|
@@ -22,6 +22,22 @@ provision_account_dir() {
|
|
|
22
22
|
|
|
23
23
|
mkdir -p "$ACCOUNT_DIR/agents/admin" "$ACCOUNT_DIR/.claude" "$ACCOUNT_DIR/specialists/.claude-plugin" "$ACCOUNT_DIR/specialists/agents"
|
|
24
24
|
|
|
25
|
+
# --- Account filesystem schema seed (idempotent) ---------------------------
|
|
26
|
+
# Operator-data buckets (entity-anchored, flat) + tool-owned dirs. agents/ and
|
|
27
|
+
# specialists/ are created above. The seeded SCHEMA.md is the single source of
|
|
28
|
+
# the allowed top-level set, parsed at runtime by the fs-schema write guard.
|
|
29
|
+
local _seed_dirs=(projects contacts documents url-get output generated extracted uploads)
|
|
30
|
+
local _d _seeded=0
|
|
31
|
+
for _d in "${_seed_dirs[@]}"; do
|
|
32
|
+
[ -d "$ACCOUNT_DIR/$_d" ] || { mkdir -p "$ACCOUNT_DIR/$_d" && _seeded=$((_seeded+1)); }
|
|
33
|
+
done
|
|
34
|
+
if [ -f "$TEMPLATES_DIR/account-schema/SCHEMA.md" ]; then
|
|
35
|
+
cp "$TEMPLATES_DIR/account-schema/SCHEMA.md" "$ACCOUNT_DIR/SCHEMA.md"
|
|
36
|
+
else
|
|
37
|
+
echo " [acct-schema] WARNING: template missing at $TEMPLATES_DIR/account-schema/SCHEMA.md — SCHEMA.md not seeded" >&2
|
|
38
|
+
fi
|
|
39
|
+
echo " [acct-schema] seeded dirs=$_seeded"
|
|
40
|
+
|
|
25
41
|
# Claude Code discovers project-level .claude/ relative to the nearest .git
|
|
26
42
|
# root. Without a .git in the account dir it traverses up to ~/maxy/.git and
|
|
27
43
|
# misses accountDir/.claude/. Initialise a repo so the account dir IS the root.
|
|
@@ -42,19 +58,22 @@ provision_account_dir() {
|
|
|
42
58
|
{
|
|
43
59
|
"matcher": "Write",
|
|
44
60
|
"hooks": [
|
|
45
|
-
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" }
|
|
61
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
|
|
62
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/fs-schema-guard.sh" }
|
|
46
63
|
]
|
|
47
64
|
},
|
|
48
65
|
{
|
|
49
66
|
"matcher": "Edit",
|
|
50
67
|
"hooks": [
|
|
51
|
-
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" }
|
|
68
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
|
|
69
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/fs-schema-guard.sh" }
|
|
52
70
|
]
|
|
53
71
|
},
|
|
54
72
|
{
|
|
55
73
|
"matcher": "NotebookEdit",
|
|
56
74
|
"hooks": [
|
|
57
|
-
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" }
|
|
75
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/archive-ingest-surface-gate.sh" },
|
|
76
|
+
{ "type": "command", "command": "bash $HOOKS_PATH/fs-schema-guard.sh" }
|
|
58
77
|
]
|
|
59
78
|
},
|
|
60
79
|
{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAa3B,OAAO,
|
|
1
|
+
{"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAa3B,OAAO,EAmBL,KAAK,SAAS,EAEf,MAAM,kBAAkB,CAAA;AAuBzB,OAAO,KAAK,EAAE,SAAS,EAAyB,MAAM,iBAAiB,CAAA;AAEvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAA;AAC1D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,uBAAuB,CAAA;AAC9E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAA;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAA;AAgFhE,eAAO,MAAM,kBAAkB,QAA2B,CAAA;AAS1D,eAAO,MAAM,4BAA4B,QAAS,CAAA;AAIlD,MAAM,WAAW,QAAS,SAAQ,IAAI,CAAC,SAAS,EAAE,gBAAgB,GAAG,SAAS,CAAC;IAC7E;;qEAEiE;IACjE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,SAAS,CAAA;IAClB,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,kBAAkB,EAAE,WAAW,CAAA;IAC/B,eAAe,EAAE,aAAa,CAAA;IAC9B;kFAC8E;IAC9E,cAAc,EAAE,cAAc,CAAA;IAC9B;;gFAE4E;IAC5E,cAAc,EAAE,cAAc,CAAA;IAC9B;;;6BAGyB;IACzB,mBAAmB,CAAC,EAAE,mBAAmB,CAAA;IACzC;;gFAE4E;IAC5E,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAgHD,wBAAgB,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAEpG;AAgBD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE1D;AAUD,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAMvE;AAMD,wBAAgB,gBAAgB,CAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,EACxB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,GAAG,IAAI,GACtB,MAAM,CAER;AAkCD,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAe5F;AAwJD;;;kEAGkE;AAClE,MAAM,MAAM,OAAO,GAAG,IAAI,GAAG;IAC3B,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpD;;;+CAG2C;IAC3C,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;CAChD,CAAA;AAmBD,wBAAgB,YAAY,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAk+EpD"}
|
|
@@ -31,7 +31,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
31
31
|
import { execFileSync } from 'node:child_process';
|
|
32
32
|
import { buildRcChildEnv, reapplyAdminLevers } from './rc-daemon.js';
|
|
33
33
|
import { isEffortLevel, isNewSessionEffortLevel, isPermissionMode, SONNET_MODEL } from '../../../lib/models/dist/index.js';
|
|
34
|
-
import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, liveSessionIds, getPtyTrackerForTests, livePidForSession, classifyLiveness, priorExitedCountForSession, PERMISSION_MODES, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
|
|
34
|
+
import { spawnClaudeSession, stopSession, writeInputToPty, writeInterruptToPty, isLive, onPtyExit, livePtyCount, liveSessionIds, getPtyTrackerForTests, livePidForSession, classifyLiveness, priorExitedCountForSession, PERMISSION_MODES, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
|
|
35
35
|
import { writeScopeRecord, removeScopeRecord } from './scope-record.js';
|
|
36
36
|
import { writeReseatMarker, resolveReseatChain, clearReseatMarker } from './reseat-ledger.js';
|
|
37
37
|
import { getScopeMainPid, defaultProcessSignals, defaultSystemctlRunner } from './systemd-scope.js';
|
|
@@ -1567,6 +1567,50 @@ export function buildHttpApp(deps) {
|
|
|
1567
1567
|
timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
|
|
1568
1568
|
return c.json({ ok: true, bytes: result.bytes }, 200);
|
|
1569
1569
|
});
|
|
1570
|
+
// Task 1090 Phase 1 — POST /:sessionId/interrupt writes one raw control byte
|
|
1571
|
+
// (ESC or Ctrl-C) to the PTY master via writeInterruptToPty: the soft
|
|
1572
|
+
// interrupt, session-preserving, distinct from /stop's SIGTERM recycle. The
|
|
1573
|
+
// byte is a body parameter so the device spike can test both candidates
|
|
1574
|
+
// against one live session. This logs the requested/sent lifecycle; the
|
|
1575
|
+
// deeper post-conditions (generation-stopped, survival, tail-reconcile) are
|
|
1576
|
+
// observed manually during the spike and engineered in Phase 2 on a GO.
|
|
1577
|
+
app.post('/:sessionId/interrupt', async (c) => {
|
|
1578
|
+
const start = Date.now();
|
|
1579
|
+
const id = c.req.param('sessionId');
|
|
1580
|
+
const row = resolveRow(deps.watcher, id);
|
|
1581
|
+
const sessionId = row?.sessionId ?? id;
|
|
1582
|
+
let body;
|
|
1583
|
+
try {
|
|
1584
|
+
body = await c.req.json();
|
|
1585
|
+
}
|
|
1586
|
+
catch {
|
|
1587
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 400, Date.now() - start);
|
|
1588
|
+
return c.json({ error: 'body-not-json' }, 400);
|
|
1589
|
+
}
|
|
1590
|
+
if (body.byte !== 'esc' && body.byte !== 'ctrl-c') {
|
|
1591
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 400, Date.now() - start);
|
|
1592
|
+
return c.json({ error: 'byte-invalid' }, 400);
|
|
1593
|
+
}
|
|
1594
|
+
const byte = body.byte;
|
|
1595
|
+
const intId = `${sessionId.slice(0, 8)}-${start}`;
|
|
1596
|
+
deps.logger(`[webchat-interrupt] op=interrupt-requested intId=${intId} sessionId=${sessionId.slice(0, 8)} trigger=manual-spike`);
|
|
1597
|
+
const result = writeInterruptToPty(sessionId, byte, deps.logger);
|
|
1598
|
+
if (!result.ok) {
|
|
1599
|
+
if (result.error === 'not-found') {
|
|
1600
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 404, Date.now() - start);
|
|
1601
|
+
return c.json({ error: 'session-not-found' }, 404);
|
|
1602
|
+
}
|
|
1603
|
+
if (result.error === 'exited') {
|
|
1604
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 410, Date.now() - start);
|
|
1605
|
+
return c.json({ error: 'session-exited' }, 410);
|
|
1606
|
+
}
|
|
1607
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 500, Date.now() - start);
|
|
1608
|
+
return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
|
|
1609
|
+
}
|
|
1610
|
+
deps.logger(`[webchat-interrupt] op=interrupt-sent intId=${intId} byte=${byte} bytes=${result.bytes}`);
|
|
1611
|
+
timed(deps.logger, 'POST', `/${id}/interrupt`, 200, Date.now() - start);
|
|
1612
|
+
return c.json({ ok: true, intId, byte, bytes: result.bytes }, 200);
|
|
1613
|
+
});
|
|
1570
1614
|
app.get('/:sessionId/log', (c) => {
|
|
1571
1615
|
const start = Date.now();
|
|
1572
1616
|
const id = c.req.param('sessionId');
|