@cratis/pi 3.2.1 → 3.3.0

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.
@@ -7,13 +7,18 @@
7
7
  * events and drives the SAME scripts, synthesizing the Claude hook JSON they read on stdin:
8
8
  *
9
9
  * Claude PreToolUse (Write|Edit) → Pi `tool_call` → cratis-guard-writes.sh (exit 2 = block)
10
+ * Claude PreToolUse (Bash) → Pi `tool_call` → cratis-guard-store-mutations.sh (exit 2 = block)
10
11
  * Claude PostToolUse (Write|Edit) → Pi `tool_result` → cratis-pattern-scan.sh (advisory context)
11
12
  * Claude Stop → Pi `agent_settled` → cratis-quality-gate.sh (exit 2 = keep going)
12
13
  *
13
14
  * Nothing here duplicates corpus content: it is adapter machinery, the Pi peer of the Claude
14
15
  * `hooks` block in `.claude/settings.json`. Every environment escape hatch the scripts honor
15
- * (CRATIS_HOOKS_ALLOW_PROTECTED_WRITES, CRATIS_HOOKS_SKIP_SCAN, CRATIS_HOOKS_SKIP_GATE, …) still
16
- * works because the scripts are executed unchanged.
16
+ * (CRATIS_HOOKS_ALLOW_PROTECTED_WRITES, CRATIS_HOOKS_ALLOW_STORE_MUTATIONS, CRATIS_HOOKS_SKIP_SCAN,
17
+ * CRATIS_HOOKS_SKIP_GATE, …) still works because the scripts are executed unchanged, in the environment
18
+ * Pi was started from.
19
+ *
20
+ * Only Pi's `bash` tool is bridged to the store-mutation guard. A command the user types directly (`!cmd`,
21
+ * the `user_bash` event) is the person's own action and is deliberately not guarded.
17
22
  */
18
23
 
19
24
  import { spawn } from "node:child_process";
@@ -85,6 +90,37 @@ function writeTarget(toolName: string, input: any): { filePath?: string; content
85
90
  return { filePath };
86
91
  }
87
92
 
93
+ type ToolCallContext = { cwd: string; signal?: AbortSignal };
94
+
95
+ /**
96
+ * Run a blocking PreToolUse guard and translate its verdict into Pi's `tool_call` result.
97
+ *
98
+ * Exit 2 blocks with the script's stderr as the reason. A guard that is installed but could not run blocks too:
99
+ * allowing there would let the one case the guard exists to catch pass silently precisely because the guard
100
+ * is broken, so a broken guard is loud rather than permissive.
101
+ */
102
+ async function runBlockingGuard(
103
+ script: string,
104
+ name: string,
105
+ subject: string,
106
+ payload: object,
107
+ ctx: ToolCallContext,
108
+ fixTarget = "this guard",
109
+ ): Promise<{ block: true; reason: string } | undefined> {
110
+ const run = await runScript(script, JSON.stringify(payload), ctx.cwd, ctx.signal);
111
+ if (run.failed) {
112
+ return {
113
+ block: true,
114
+ reason:
115
+ `${name} is installed at ${script} but could not be run, so this ${subject} cannot be checked.` +
116
+ `${run.stderr.trim() ? `\n\n${run.stderr.trim()}` : ""}` +
117
+ `\n\nFix the script (or remove it if this repository is not meant to enforce ${fixTarget}) and retry.`,
118
+ };
119
+ }
120
+ if (run.code === 2) return { block: true, reason: run.stderr.trim() || `Blocked by ${name}.` };
121
+ return undefined;
122
+ }
123
+
88
124
  /**
89
125
  * Whether a hook script is installed at all.
90
126
  *
@@ -109,6 +145,7 @@ export default function (pi: ExtensionAPI) {
109
145
  const managedScriptsDir = path.join(process.cwd(), ".cratis", "ai", "hooks", "scripts");
110
146
  const scriptsDir = fs.existsSync(managedScriptsDir) ? managedScriptsDir : path.join(bundledCorpusRoot, "hooks", "scripts");
111
147
  const guardWrites = path.join(scriptsDir, "cratis-guard-writes.sh");
148
+ const guardStoreMutations = path.join(scriptsDir, "cratis-guard-store-mutations.sh");
112
149
  const patternScan = path.join(scriptsDir, "cratis-pattern-scan.sh");
113
150
  const qualityGate = path.join(scriptsDir, "cratis-quality-gate.sh");
114
151
 
@@ -119,28 +156,21 @@ export default function (pi: ExtensionAPI) {
119
156
  if (event.source !== "extension") gateActive = false; // a fresh user turn resets the guard
120
157
  });
121
158
 
122
- // ── PreToolUse → guard writes (blocking) ──
159
+ // ── PreToolUse → guard writes and store-mutating cratis commands (blocking) ──
123
160
  pi.on("tool_call", async (event, ctx) => {
161
+ if (event.toolName === "bash") {
162
+ const command = (event as any).input?.command;
163
+ if (typeof command !== "string" || !command.trim()) return;
164
+ if (!isInstalled(guardStoreMutations)) return; // no guard installed in this repository - nothing to enforce
165
+ const payload = { cwd: ctx.cwd, tool_name: "Bash", tool_input: { command } };
166
+ return runBlockingGuard(guardStoreMutations, "cratis-guard-store-mutations", "command", payload, ctx);
167
+ }
124
168
  if (event.toolName !== "write" && event.toolName !== "edit") return;
125
169
  const { filePath, content, newString } = writeTarget(event.toolName, (event as any).input);
126
170
  if (!filePath) return;
127
171
  if (!isInstalled(guardWrites)) return; // no guard installed in this repository - nothing to enforce
128
- const payload = JSON.stringify({ cwd: ctx.cwd, tool_input: { file_path: filePath, content, new_string: newString } });
129
- const run = await runScript(guardWrites, payload, ctx.cwd, ctx.signal);
130
-
131
- // The guard is installed but could not run. Allowing here would mean the one case the guard
132
- // exists to catch - a protected write - passes silently precisely because the guard is broken.
133
- // Block instead, and say why, so a broken guard is loud rather than permissive.
134
- if (run.failed) {
135
- return {
136
- block: true,
137
- reason:
138
- `cratis-guard-writes is installed at ${guardWrites} but could not be run, so this write cannot be checked.` +
139
- `${run.stderr.trim() ? `\n\n${run.stderr.trim()}` : ""}` +
140
- "\n\nFix the script (or remove it if this repository is not meant to enforce write guards) and retry.",
141
- };
142
- }
143
- if (run.code === 2) return { block: true, reason: run.stderr.trim() || "Blocked by cratis-guard-writes." };
172
+ const payload = { cwd: ctx.cwd, tool_input: { file_path: filePath, content, new_string: newString } };
173
+ return runBlockingGuard(guardWrites, "cratis-guard-writes", "write", payload, ctx, "write guards");
144
174
  });
145
175
 
146
176
  // ── PostToolUse → deterministic pattern scan (advisory; injects reminders the model sees) ──
@@ -10,6 +10,7 @@ Three layers:
10
10
  |---|---|---|---|---|
11
11
  | Pattern pass | `PostToolUse` on a write | `scripts/cratis-pattern-scan.sh` | zero tokens until a match | appends a one-line reminder to context, never blocks |
12
12
  | Hard block | `PreToolUse` on a write | `scripts/cratis-guard-writes.sh` | zero | exits **2** — the write does not happen |
13
+ | Hard block | `PreToolUse` on `Bash` | `scripts/cratis-guard-store-mutations.sh` | zero; parses only commands that mention `cratis` | exits **2** — the store-changing `cratis chronicle` command does not run |
13
14
  | Quality gate | `Stop` | `scripts/cratis-quality-gate.sh` | one build/test run, only when relevant files changed | exits **2** — the turn does not end |
14
15
 
15
16
  The Claude Code wiring that fires them is tracked here, in
@@ -31,8 +32,9 @@ The markdown files in this folder (`agent-stop.md`, `pre-commit.md`) remain *lif
31
32
  they describe what a hook should do for tools that have no wiring yet.
32
33
 
33
34
  > Hooks are the one surface with no folder adapter: Claude Code reads `.claude/settings.json`;
34
- > the Pi harness bridges the same three scripts to its own events through the `cratis-hooks`
35
- > extension under `../harnesses/pi/extensions/`; Copilot would read `.github/hooks/*.json`, and no
35
+ > the Pi harness bridges the same scripts to its own events through the `cratis-hooks`
36
+ > extension under `../harnesses/pi/extensions/` (its `bash` tool feeds the store-mutation guard, its
37
+ > `write` and `edit` tools the write guard); Copilot would read `.github/hooks/*.json`, and no
36
38
  > Copilot wiring ships yet.
37
39
 
38
40
  ## What is enforced
@@ -50,6 +52,42 @@ Rule numbers refer to the numbered list in [`../rules/general.md`](../rules/gene
50
52
  The generated-file check is anchored: the marker must be a comment opener at the start of one of
51
53
  the first five lines. A rule file or a document that merely *mentions* the marker is not blocked.
52
54
 
55
+ **Blocked outright** (`PreToolUse` on `Bash`, exit 2): a `cratis chronicle` command that is not
56
+ read-only. The **cratis-chronicle-cli-operations** skill makes `--yes` the authorization boundary for
57
+ a live store and says a request to diagnose does not authorize a mutation; this guard enforces that.
58
+
59
+ - **Allowlist, failing closed.** Every command below `cratis chronicle` is checked against the
60
+ read-only allowlist in `scripts/cratis-store-mutations.json`. A known mutation (replay,
61
+ retry-partition, clear-quarantine, jobs stop/resume, recommendations perform/ignore, users,
62
+ applications and subscriptions add/remove, plus login, logout, report-error and the interactive
63
+ workbench) is blocked with what it changes; a command on neither list — including one a newer CLI
64
+ adds — is blocked as unknown. `-h`/`--help` and `-v`/`--version` are always allowed.
65
+ - **Where it looks.** Every `cratis` in command position: at the start, after `;` `&&` `||` `|`
66
+ `&`, inside `$( )`, backticks and `( )`, behind environment assignments
67
+ (`CHRONICLE_CONNECTION_STRING=… cratis …`), keywords (`if`, `then`, `do`, …) and wrappers (`rtk
68
+ proxy`, `env`, `sudo`, `timeout`, `xargs`, `nohup`, …), in the script of `bash -c` / `sh -c` /
69
+ `eval`, and in text piped, redirected or here-documented into a shell. A path segment
70
+ (`~/repos/cratis/Chronicle`), a file name (`cratis.json`), a quoted argument
71
+ (`grep 'cratis chronicle …'`) and a here-document fed to anything but a shell are not commands.
72
+ - **Options before the command path.** The global and group options (`--server`, `-o`, `-q`,
73
+ `-y`, `-e`, `-n`, `--debug`) are skipped with their values wherever they appear. An option the
74
+ guard does not know, placed before the command path, makes the command impossible to classify, so
75
+ it is blocked.
76
+ - **Out of scope.** Every other group — `ai`, `arc`, `context`, `llm`, `screenplay`, `prologue`,
77
+ `completions` — and the top-level commands (`init`, `new`, `render`, `run`, `update`, `version`,
78
+ `get-started`, `llm-context`) are always allowed. Several of them change local files or
79
+ configuration; guarding them is a separate decision, not part of this one.
80
+ - **What it cannot see.** It reads the command text, so `cratis` reached through a variable, an
81
+ alias, a shell function, a script file, or another language's process API is invisible. It is a
82
+ guardrail against an agent reaching for `--yes`, not a sandbox.
83
+ - **The block message** lists each refused command path and its effect, tells the agent not to retry
84
+ or work around the guard, and asks it to report the exact command, the target context or server,
85
+ what it changes and why, and to ask the user.
86
+
87
+ A person who has authorized a mutation sets `CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1` in the
88
+ environment the harness was started from. The hook reads its own environment, so an assignment
89
+ inside the command (`CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1 cratis …`) changes nothing.
90
+
53
91
  **Flagged** (`PostToolUse`, exit 0 + context):
54
92
 
55
93
  | Pattern id | Rule | Detects |
@@ -271,14 +309,16 @@ how it resolved rather than only the failures.
271
309
 
272
310
  ## Configuration is data, not code
273
311
 
274
- Neither the pattern list nor the gate commands live in a script. A consuming repository
275
- customises both without forking anything:
312
+ Neither the pattern list, the gate commands nor the `cratis` command classification live in a
313
+ script. A consuming repository customises all three without forking anything:
276
314
 
277
315
  | File | Purpose |
278
316
  |---|---|
279
317
  | `scripts/cratis-patterns.json` | shipped pattern set; its header `$comment` documents every field |
280
318
  | `scripts/cratis-patterns.local.json` | optional; merged over the above by `id` — add patterns, or set `"enabled": false` to silence one |
281
319
  | `scripts/quality-gates.json` | shipped gates; `changed` globs decide when a gate runs, `requires` and `workingDirectoryFrom` decide whether it *can* |
320
+ | `scripts/cratis-store-mutations.json` | the store-mutation guard's read-only allowlist and known-mutating list, each entry with its reason or effect, and the CLI options it skips; its header `$comment` documents every field and the CLI version the lists were derived from |
321
+ | `scripts/cratis-store-mutations.local.json` | optional; its lists are appended to the above. It can classify a command a newer CLI adds, or list a shipped read-only command as mutating (a command on the mutating list always blocks); only a replacement file can make a known mutation read-only |
282
322
 
283
323
  A gate whose `requires.commands` are not on `PATH`, whose `requires.paths` do not exist, or whose
284
324
  `workingDirectoryFrom` matches nothing in the repository, is a **no-op with a message on stderr**
@@ -325,6 +365,8 @@ Each is an explicit, auditable opt-out — none of them is a default.
325
365
  | `CRATIS_HOOKS_GATE_DRYRUN=1` | prints which gates would run, and why, then exits 0 |
326
366
  | `CRATIS_HOOKS_PATTERNS=<path>` | replaces the pattern file |
327
367
  | `CRATIS_HOOKS_GATES=<path>` | replaces the gate file (the project override still merges over it) |
368
+ | `CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1` | allows `cratis chronicle` commands that change a live store; set by the person who authorized the mutation, in the environment the harness was started from |
369
+ | `CRATIS_HOOKS_STORE_MUTATIONS=<path>` | replaces the store-mutation guard's command lists (the `.local.json` beside the script still extends them) |
328
370
  | `CRATIS_HOOKS_SUBPATH_REPORT=1` | prints every `@cratis/*` subpath reference and how it resolved, not only the failures |
329
371
  | `CRATIS_HOOKS_IMPORT_REPORT=1` | prints every `@cratis/*` named import binding and how it resolved, not only the failures |
330
372
  | `CRATIS_HOOKS_TYPE_REPORT=1` | prints every .NET type/attribute name the corpus mentions and how it resolved, not only the failures |
@@ -338,7 +380,10 @@ Each is an explicit, auditable opt-out — none of them is a default.
338
380
  - **`jq` is the only dependency.** Every script
339
381
  degrades to a silent no-op when it is missing — a hook must never break a session.
340
382
  - **Fail safe.** Malformed config, empty stdin, a missing file, a binary file, a file over 2 MB:
341
- all exit 0 silently.
383
+ all exit 0 silently. The one deliberate exception is the store-mutation guard's command lists:
384
+ an unreadable shipped or local list is not an empty allowlist that happens to pass, so every `cratis chronicle`
385
+ command is then blocked with a message naming the file, and a classifier that fails to run blocks
386
+ the command it was given. Commands that never mention `cratis` are unaffected either way.
342
387
  - **No secrets, no file dumps.** Gate output is capped at `maxOutputLines`; the pattern pass
343
388
  prints a path, a line number and a fixed message — never file content.
344
389
  - **No re-entry.** The `Stop` hook returns immediately when `stop_hook_active` is true, so a
@@ -367,6 +412,12 @@ jq -nc '{session_id:"t", cwd:"'"$PWD"'", tool_name:"Edit",
367
412
  tool_input:{file_path:"'"$PWD"'/Directory.Packages.props", new_string:"x"}}' \
368
413
  | .cratis/ai/hooks/scripts/cratis-guard-writes.sh; echo "exit=$?"
369
414
 
415
+ # Store-mutation guard, both directions: expect exit 2, then exit 0 for the read-only neighbor
416
+ jq -nc '{tool_name:"Bash", tool_input:{command:"cratis chronicle observers replay my-observer --yes"}}' \
417
+ | .cratis/ai/hooks/scripts/cratis-guard-store-mutations.sh; echo "exit=$?"
418
+ jq -nc '{tool_name:"Bash", tool_input:{command:"cratis chronicle observers list -o plain"}}' \
419
+ | .cratis/ai/hooks/scripts/cratis-guard-store-mutations.sh; echo "exit=$?"
420
+
370
421
  # Quality gate — show the dispatch plan without running anything
371
422
  jq -nc '{session_id:"t", cwd:"'"$PWD"'", stop_hook_active:false}' \
372
423
  | CRATIS_HOOKS_GATE_DRYRUN=1 .cratis/ai/hooks/scripts/cratis-quality-gate.sh
@@ -0,0 +1,470 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook (Bash) — hard block on `cratis chronicle` commands that are not read-only.
3
+ #
4
+ # The cratis-chronicle-cli-operations skill makes `--yes` the authorization boundary for changing a
5
+ # live Chronicle store, and says a request to diagnose does not authorize a mutation. This guard is
6
+ # what enforces that: every `cratis` invocation in the shell command is found and, for the
7
+ # `chronicle` group, classified against cratis-store-mutations.json. Anything that is not on the
8
+ # read-only allowlist — replay, retry, quarantine clearing, job control, recommendations, users,
9
+ # applications, subscriptions, and any command a newer CLI adds — exits 2 (block the tool call,
10
+ # stderr goes back to the model). Unknown commands fail closed.
11
+ #
12
+ # Other groups (ai, arc, context, llm, screenplay, prologue, completions) and the top-level commands
13
+ # (init, new, render, run, update, version, ...) are out of scope and always allowed.
14
+ #
15
+ # What it sees is the command text. A `cratis` reached through a variable, an alias, a function or a
16
+ # script file is invisible to it; see .cratis/ai/hooks/README.md for the full list of limits.
17
+ #
18
+ # Escape hatch for a human who authorized the mutation — set in the environment the agent harness was
19
+ # started from, never inside the command (an assignment in the command is ignored):
20
+ # CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1
21
+ # Data file override (replaces the shipped lists; a .local.json beside this script only adds to them):
22
+ # CRATIS_HOOKS_STORE_MUTATIONS=<path>
23
+ set -euo pipefail
24
+
25
+ # SCRIPTDIR, not a path relative to the caller: shellcheck resolves a plain relative `source=`
26
+ # against the current working directory, and these hooks are linted from wherever CI happens to run.
27
+ # shellcheck source=SCRIPTDIR/hook-lib.sh
28
+ . "$(dirname "${BASH_SOURCE[0]}")/hook-lib.sh"
29
+
30
+ [ "${CRATIS_HOOKS_ALLOW_STORE_MUTATIONS:-0}" = "1" ] && exit 0
31
+
32
+ input="$(hook_read_stdin)"
33
+ [ -n "$input" ] || exit 0
34
+ hook_have jq || exit 0
35
+
36
+ command_text="$(hook_json "$input" '.tool_input.command')"
37
+ [ -n "$command_text" ] || exit 0
38
+
39
+ # Cheap exit for the overwhelming majority of shell commands, which never mention the CLI.
40
+ case "$(printf '%s' "$command_text" | tr '[:upper:]' '[:lower:]')" in
41
+ *cratis*) ;;
42
+ *) exit 0 ;;
43
+ esac
44
+
45
+ # ── Command lists: shipped defaults + optional local extension, or a replacement ─────────
46
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
47
+ data_file="${CRATIS_HOOKS_STORE_MUTATIONS:-$here/cratis-store-mutations.json}"
48
+ local_file="$here/cratis-store-mutations.local.json"
49
+
50
+ merged=""
51
+ error_file="$data_file"
52
+ if [ -f "$data_file" ]; then
53
+ merged="$(jq -ce 'select(type == "object" and (.group | type == "string") and
54
+ (.readOnly | type == "array") and (.mutating | type == "array") and
55
+ (.helpOptions | type == "array") and (.versionOptions | type == "array") and
56
+ (.flagOptions | type == "array") and (.valueOptions | type == "array"))' "$data_file" 2>/dev/null || true)"
57
+ fi
58
+ if [ -n "$merged" ] && [ -f "$local_file" ]; then
59
+ # A local file adds entries; it never removes one. A command it lists as mutating is blocked even
60
+ # when the shipped file calls it read-only, because the classifier checks "mutating" first.
61
+ extended="$(printf '%s' "$merged" | jq -c --slurpfile local "$local_file" '
62
+ . as $base | $local[0] as $add
63
+ | if ($add | type) != "object" or
64
+ (["readOnly", "mutating", "helpOptions", "versionOptions", "flagOptions", "valueOptions"]
65
+ | any(. as $key | ($add[$key] != null and ($add[$key] | type) != "array")))
66
+ then error("invalid local list") else . end
67
+ | $base + (["readOnly", "mutating", "helpOptions", "versionOptions", "flagOptions", "valueOptions"]
68
+ | map({key: ., value: (($base[.] // []) + ($add[.] // []))}) | from_entries)
69
+ ' 2>/dev/null || true)"
70
+ if [ -n "$extended" ]; then
71
+ merged="$extended"
72
+ else
73
+ merged=""
74
+ error_file="$local_file"
75
+ fi
76
+ fi
77
+
78
+ # An unreadable list is not an empty allowlist that happens to pass: every chronicle command is then
79
+ # refused, and the message says why.
80
+ data_ok=1
81
+ [ -n "$merged" ] || { data_ok=0; merged='{}'; }
82
+
83
+ list() {
84
+ printf '%s' "$merged" | jq -r "$1" 2>/dev/null || true
85
+ }
86
+
87
+ group="$(list '.group // "chronicle"')"
88
+ read_only="$(list '(.readOnly // [])[] | .command // empty')"
89
+ mutating="$(list '(.mutating // [])[] | select(.command) | "\(.command)\t\(.effect // "")"')"
90
+ help_options="$(list '(.helpOptions // [])[]')"
91
+ version_options="$(list '(.versionOptions // [])[]')"
92
+ flag_options="$(list '(.flagOptions // [])[]')"
93
+ value_options="$(list '(.valueOptions // [])[]')"
94
+
95
+ tab_char="$(printf '\t')"
96
+
97
+ # ── Find and classify every cratis invocation ─────────────────────────────────────────────
98
+ # One awk pass: a small shell tokenizer (quotes, escapes, $( ) and backticks, subshells, pipelines
99
+ # and lists, redirections, here-documents) splits the command into simple commands; each is checked
100
+ # for `cratis` in command position, behind env assignments, keywords and wrappers (rtk proxy, env,
101
+ # sudo, timeout, xargs, ...). `bash -c '...'`, `eval ...` and scripts piped or redirected into a
102
+ # shell are queued and tokenized again. Output: one line per blocked invocation,
103
+ # <kind> TAB <command path> TAB <effect>
104
+ # where kind is mutating, unknown or unparsed.
105
+ findings="$(
106
+ CSM_COMMAND="$command_text" \
107
+ CSM_GROUP="$group" \
108
+ CSM_READONLY="$read_only" \
109
+ CSM_MUTATING="$mutating" \
110
+ CSM_HELP="$help_options" \
111
+ CSM_VERSION="$version_options" \
112
+ CSM_FLAGS="$flag_options" \
113
+ CSM_VALUES="$value_options" \
114
+ LC_ALL=C awk '
115
+ function toset(text, set, parts, n, k) {
116
+ n = split(text, parts, "\n")
117
+ for (k = 1; k <= n; k++) if (parts[k] != "") set[tolower(parts[k])] = 1
118
+ }
119
+ function words_in(text, parts) { return split(text, parts, " ") }
120
+ function norm(text) {
121
+ text = tolower(text); gsub(/[ \t]+/, " ", text); sub(/^ /, "", text); sub(/ $/, "", text)
122
+ return text
123
+ }
124
+ function report(kind, path, effect, key) {
125
+ key = kind SUBSEP path
126
+ if (key in reported) return
127
+ reported[key] = 1
128
+ printf "%s\t%s\t%s\n", kind, path, effect
129
+ }
130
+ function enqueue(text) {
131
+ if (text == "" || !index(tolower(text), "cratis")) return
132
+ queue[++queued] = text
133
+ }
134
+ function base(word) { word = tolower(word); sub(/.*\//, "", word); return word }
135
+
136
+ # ── tokenizer state ──
137
+ function end_word() {
138
+ if (inword) {
139
+ if (index(tolower(word), "cratis") && candidates < 256) candidate[++candidates] = word
140
+ if (skip_next) skip_next = 0
141
+ else current = current (current == "" ? "" : SEP) (quoted ? "Q" : "U") word
142
+ }
143
+ word = ""; quoted = 0; inword = 0
144
+ }
145
+ function emit() {
146
+ end_word()
147
+ if (current != "") analyze(current)
148
+ current = ""
149
+ }
150
+ function push(kind) {
151
+ depth++
152
+ s_current[depth] = current; s_word[depth] = word; s_quoted[depth] = quoted
153
+ s_inword[depth] = inword; s_dq[depth] = in_dq; s_kind[depth] = frame
154
+ s_parens[depth] = parens; s_skip[depth] = skip_next
155
+ current = ""; word = ""; quoted = 0; inword = 0; in_dq = 0
156
+ frame = kind; parens = 0; skip_next = 0
157
+ }
158
+ function pop() {
159
+ emit()
160
+ current = s_current[depth]; word = s_word[depth] "$()"; quoted = s_quoted[depth]
161
+ inword = 1; in_dq = s_dq[depth]; frame = s_kind[depth]
162
+ parens = s_parens[depth]; skip_next = s_skip[depth]
163
+ depth--
164
+ }
165
+ # Index just past the parenthesis (or brace) group opening at i.
166
+ function skip_group(s, i, opener, closer, n, level, c) {
167
+ n = length(s); level = 0
168
+ for (; i <= n; i++) {
169
+ c = substr(s, i, 1)
170
+ if (c == opener) level++
171
+ else if (c == closer) { level--; if (level == 0) return i + 1 }
172
+ }
173
+ return n + 1
174
+ }
175
+ # Read the bodies of the pending here-documents, which start at i. A body is data unless a
176
+ # shell reads it as a script, so it becomes a candidate rather than a command.
177
+ function heredocs(s, i, n, k, nl, line, body, test) {
178
+ n = length(s)
179
+ for (k = 1; k <= pending; k++) {
180
+ body = ""
181
+ while (i <= n) {
182
+ nl = index(substr(s, i), "\n")
183
+ if (nl == 0) { line = substr(s, i); i = n + 1 } else { line = substr(s, i, nl - 1); i += nl }
184
+ test = line
185
+ if (strip[k]) sub(/^\t+/, "", test)
186
+ if (test == delimiter[k]) break
187
+ body = body line "\n"
188
+ }
189
+ if (index(tolower(body), "cratis") && candidates < 256) candidate[++candidates] = body
190
+ }
191
+ pending = 0
192
+ return i
193
+ }
194
+
195
+ function parse(s, n, i, c, c2, j, k) {
196
+ current = ""; word = ""; quoted = 0; inword = 0; skip_next = 0
197
+ in_sq = 0; in_dq = 0; depth = 0; frame = "top"; parens = 0
198
+ pending = 0; candidates = 0; stdin_shell = 0
199
+ n = length(s); i = 1
200
+ while (i <= n) {
201
+ c = substr(s, i, 1)
202
+ if (in_sq) {
203
+ if (c == "\047") in_sq = 0; else word = word c
204
+ i++; continue
205
+ }
206
+ if (in_dq) {
207
+ if (c == "\\") {
208
+ c2 = substr(s, i + 1, 1)
209
+ if (c2 == "\n") { i += 2; continue }
210
+ if (c2 == "\"" || c2 == "\\" || c2 == "$" || c2 == "`") { word = word c2; i += 2; continue }
211
+ word = word c; i++; continue
212
+ }
213
+ if (c == "\"") { in_dq = 0; i++; continue }
214
+ if (c == "$" && substr(s, i + 1, 1) == "(") {
215
+ if (substr(s, i + 2, 1) == "(") { j = skip_group(s, i + 1, "(", ")"); word = word substr(s, i, j - i); i = j; continue }
216
+ push("subst"); i += 2; continue
217
+ }
218
+ if (c == "`") { if (frame == "backtick") pop(); else push("backtick"); i++; continue }
219
+ word = word c; i++; continue
220
+ }
221
+ if (c == "\\") {
222
+ c2 = substr(s, i + 1, 1)
223
+ if (c2 != "\n") { word = word c2; inword = 1 }
224
+ i += 2; continue
225
+ }
226
+ if (c == "\047") { in_sq = 1; inword = 1; quoted = 1; i++; continue }
227
+ if (c == "\"") { in_dq = 1; inword = 1; quoted = 1; i++; continue }
228
+ if (c == "#" && !inword) { while (i <= n && substr(s, i, 1) != "\n") i++; continue }
229
+ if (c == "$") {
230
+ c2 = substr(s, i + 1, 1)
231
+ if (c2 == "(" && substr(s, i + 2, 1) == "(") { j = skip_group(s, i + 1, "(", ")"); word = word substr(s, i, j - i); inword = 1; i = j; continue }
232
+ if (c2 == "(") { push("subst"); i += 2; continue }
233
+ if (c2 == "{") { j = skip_group(s, i + 1, "{", "}"); word = word substr(s, i, j - i); inword = 1; i = j; continue }
234
+ word = word c; inword = 1; i++; continue
235
+ }
236
+ if (c == "`") { if (frame == "backtick") pop(); else push("backtick"); i++; continue }
237
+ if (c == "(") {
238
+ if (!inword && substr(s, i + 1, 1) == "(") { i = skip_group(s, i, "(", ")"); continue }
239
+ emit(); parens++; i++; continue
240
+ }
241
+ if (c == ")") {
242
+ if (parens > 0) { emit(); parens--; i++; continue }
243
+ if (frame == "subst") { pop(); i++; continue }
244
+ emit(); i++; continue
245
+ }
246
+ if (c == "<" || c == ">") {
247
+ c2 = substr(s, i + 1, 1)
248
+ if (c2 == "(") { end_word(); push("subst"); i += 2; continue }
249
+ if (inword && !quoted && word ~ /^[0-9]+$/) { word = ""; inword = 0 } else end_word()
250
+ if (c == "<" && c2 == "<") {
251
+ if (substr(s, i + 2, 1) == "<") { skip_next = 2; i += 3; continue }
252
+ i += 2; pending++; strip[pending] = 0; delimiter[pending] = ""
253
+ if (substr(s, i, 1) == "-") { strip[pending] = 1; i++ }
254
+ while (substr(s, i, 1) == " " || substr(s, i, 1) == "\t") i++
255
+ while (i <= n) {
256
+ c2 = substr(s, i, 1)
257
+ if (c2 ~ /[ \t\n;&|<>()]/) break
258
+ if (c2 != "\047" && c2 != "\"" && c2 != "\\") delimiter[pending] = delimiter[pending] c2
259
+ i++
260
+ }
261
+ continue
262
+ }
263
+ i++
264
+ if (c2 == "&") {
265
+ i++
266
+ if (substr(s, i, 1) ~ /[0-9-]/) { while (i <= n && substr(s, i, 1) ~ /[0-9-]/) i++; continue }
267
+ skip_next = 1; continue
268
+ }
269
+ if (c2 == ">" || c2 == "|" || (c == "<" && c2 == ">")) i++
270
+ skip_next = 1; continue
271
+ }
272
+ if (c == "&" && substr(s, i + 1, 1) == ">") {
273
+ end_word(); i += 2
274
+ if (substr(s, i, 1) == ">") i++
275
+ skip_next = 1; continue
276
+ }
277
+ if (c == "&" || c == ";" || c == "|") { emit(); i++; continue }
278
+ if (c == "\n") { emit(); i++; if (pending > 0) i = heredocs(s, i); continue }
279
+ if (c == " " || c == "\t" || c == "\r") { end_word(); i++; continue }
280
+ word = word c; inword = 1; i++
281
+ }
282
+ emit()
283
+ while (depth > 0) pop()
284
+ emit()
285
+ # A shell reading its script from stdin (a pipe, a here-document, a here-string, a redirect)
286
+ # runs text the tokenizer only saw as data; re-read every such text that mentions cratis.
287
+ if (stdin_shell) for (k = 1; k <= candidates; k++) enqueue(candidate[k])
288
+ }
289
+
290
+ # ── simple commands ──
291
+ function analyze(text, parts, n, k) {
292
+ n = split(text, parts, SEP)
293
+ for (k = 1; k <= n; k++) { Q[k] = substr(parts[k], 1, 1); W[k] = substr(parts[k], 2) }
294
+ count = n
295
+ from(1)
296
+ }
297
+ function joined(first, k, out) {
298
+ out = ""
299
+ for (k = first; k <= count; k++) out = out (out == "" ? "" : " ") W[k]
300
+ return out
301
+ }
302
+ function from(first, i, b, j, bj) {
303
+ i = first
304
+ while (i <= count) {
305
+ if (W[i] ~ /^[A-Za-z_][A-Za-z0-9_]*\+?=/) { i++; continue }
306
+ if (Q[i] == "U" && (W[i] in keyword)) { i++; continue }
307
+ break
308
+ }
309
+ if (i > count) return
310
+ b = base(W[i])
311
+ if (b == "cratis") { classify(i + 1); return }
312
+ if (b in shell) { shell_command(i); return }
313
+ if (b == "eval") { enqueue(joined(i + 1)); return }
314
+ if (b in wrapper) {
315
+ for (j = i + 1; j <= count; j++) {
316
+ bj = base(W[j])
317
+ if (bj == "cratis" || bj == "eval" || (bj in shell)) { from(j); return }
318
+ if (W[j] ~ /[ \t\n;&|]/) enqueue(W[j])
319
+ }
320
+ }
321
+ }
322
+ function shell_command(i, j) {
323
+ for (j = i + 1; j <= count; j++) {
324
+ if (W[j] ~ /^-[A-Za-z]*c[A-Za-z]*$/ || W[j] == "--command") {
325
+ if (j < count) enqueue(W[j + 1])
326
+ return
327
+ }
328
+ }
329
+ for (j = i + 1; j <= count; j++) {
330
+ if (W[j] == "-o" || W[j] == "+o" || W[j] == "-O" || W[j] == "+O" || W[j] == "--rcfile" || W[j] == "--init-file") { j++; continue }
331
+ if (W[j] == "--") return (j < count) ? 0 : stdin_script()
332
+ if (W[j] ~ /^[-+]/) continue
333
+ return
334
+ }
335
+ stdin_script()
336
+ }
337
+ function stdin_script() { stdin_shell = 1; return 0 }
338
+
339
+ # ── one cratis invocation: the words after `cratis` start at first ──
340
+ function is_option(lower) { return lower ~ /^-./ }
341
+ function known_value(lower, eq) {
342
+ eq = index(lower, "=")
343
+ return eq > 1 && (substr(lower, 1, eq - 1) in value_option)
344
+ }
345
+ function classify(first, j, m, x, t, lower, name, values, only_options, count_words, path, shown, key) {
346
+ values = " "
347
+ for (j = first; j <= count; j++) {
348
+ lower = tolower(W[j])
349
+ if (!is_option(lower)) break
350
+ if (Q[j] == "U" && ((lower in help_option) || (lower in version_option))) return
351
+ if (lower in flag_option) continue
352
+ if (lower in value_option) { j++; if (j <= count) values = values tolower(W[j]) " "; continue }
353
+ if (known_value(lower)) { values = values substr(lower, index(lower, "=") + 1) " "; continue }
354
+ # An option this guard does not know, before the command group: it cannot tell which word is
355
+ # the group, so any mention of the guarded group fails closed.
356
+ for (m = j + 1; m <= count; m++) {
357
+ if (tolower(W[m]) == group) { t = W[j]; sub(/=.*/, "=...", t); report("unparsed", "cratis " t " ... " group, ""); return }
358
+ }
359
+ return
360
+ }
361
+ if (j > count) return
362
+ name = tolower(W[j])
363
+ if (index(name, "$") || index(name, "`") || index(name, "*") || index(name, "?") || index(name, "[")) {
364
+ report("unparsed", "cratis " W[j], ""); return
365
+ }
366
+ if (name != group) {
367
+ if (index(values, " " group " ")) report("unparsed", "cratis ... " group, "")
368
+ return
369
+ }
370
+ for (m = j + 1; m <= count; m++) {
371
+ if (W[m] == "--") break
372
+ if (Q[m] == "U" && ((tolower(W[m]) in help_option) || (tolower(W[m]) in version_option))) return
373
+ }
374
+ count_words = 0; only_options = 1
375
+ for (m = j + 1; m <= count && count_words < max_depth; m++) {
376
+ lower = tolower(W[m])
377
+ if (lower == "--") { only_options = 0; continue }
378
+ if (only_options && is_option(lower)) {
379
+ if (lower in flag_option) continue
380
+ if (lower in value_option) { m++; continue }
381
+ if (known_value(lower)) continue
382
+ break
383
+ }
384
+ count_words++; path[count_words] = lower; shown[count_words] = W[m]
385
+ }
386
+ if (count_words == 0) { report("unknown", "cratis " W[j], ""); return }
387
+ for (m = count_words; m >= 1; m--) {
388
+ key = path[1]; t = shown[1]
389
+ for (x = 2; x <= m; x++) { key = key " " path[x]; t = t " " shown[x] }
390
+ if (key in mutating) { report("mutating", "cratis " W[j] " " t, mutating[key]); return }
391
+ if (key in read_only) return
392
+ }
393
+ t = shown[1]
394
+ for (x = 2; x <= count_words; x++) t = t " " shown[x]
395
+ report("unknown", "cratis " W[j] " " t, "")
396
+ }
397
+
398
+ BEGIN {
399
+ SEP = "\037"
400
+ group = tolower(ENVIRON["CSM_GROUP"]); if (group == "") group = "chronicle"
401
+ max_depth = 0
402
+ n = split(ENVIRON["CSM_READONLY"], lines, "\n")
403
+ for (k = 1; k <= n; k++) {
404
+ key = norm(lines[k]); if (key == "") continue
405
+ read_only[key] = 1
406
+ if (words_in(key) > max_depth) max_depth = words_in(key)
407
+ }
408
+ n = split(ENVIRON["CSM_MUTATING"], lines, "\n")
409
+ for (k = 1; k <= n; k++) {
410
+ tab = index(lines[k], "\t"); if (tab == 0) continue
411
+ key = norm(substr(lines[k], 1, tab - 1)); if (key == "") continue
412
+ mutating[key] = substr(lines[k], tab + 1)
413
+ if (words_in(key) > max_depth) max_depth = words_in(key)
414
+ }
415
+ if (max_depth < 2) max_depth = 2
416
+ toset(ENVIRON["CSM_HELP"], help_option)
417
+ toset(ENVIRON["CSM_VERSION"], version_option)
418
+ toset(ENVIRON["CSM_FLAGS"], flag_option)
419
+ toset(ENVIRON["CSM_VALUES"], value_option)
420
+ toset("bash\nsh\nzsh\ndash\nksh\nmksh\nfish\nsu", shell)
421
+ toset("!\n{\n}\nif\nthen\nelse\nelif\ndo\nwhile\nuntil\ntime", keyword)
422
+ toset("env\ncommand\nexec\nbuiltin\nnohup\ntime\nnice\nionice\nsudo\ndoas\ntimeout\ngtimeout\nxargs\nparallel\nstdbuf\nunbuffer\ncaffeinate\nwatch\nrtk\ndotnet\narch\nssh\nnoglob\nnocorrect\ntaskset\nflock\nsetsid\nchronic", wrapper)
423
+
424
+ queued = 1; queue[1] = ENVIRON["CSM_COMMAND"]
425
+ for (done = 1; done <= queued; done++) {
426
+ if (done > 64) { report("unparsed", "(more than 64 nested shell scripts)", ""); break }
427
+ if (index(tolower(queue[done]), "cratis")) parse(queue[done])
428
+ }
429
+ }
430
+ '
431
+ )" || findings="unparsed${tab_char}(the classifier failed, so no cratis command in it can be checked)${tab_char}"
432
+
433
+ [ -n "$findings" ] || exit 0
434
+
435
+ # ── Block ─────────────────────────────────────────────────────────────────────────────────
436
+ {
437
+ printf 'BLOCKED by cratis-guard-store-mutations: this command runs cratis %s commands that are not read-only.\n\n' "$group"
438
+ while IFS="$tab_char" read -r kind path effect; do
439
+ [ -n "$kind" ] || continue
440
+ case "$kind" in
441
+ mutating) printf ' - %s: %s.\n' "$path" "$effect" ;;
442
+ unparsed) printf ' - %s: the guard cannot tell which command this runs, so it is treated as a mutation.\n' "$path" ;;
443
+ *) printf ' - %s: not on the read-only allowlist, so it is treated as a mutation.\n' "$path" ;;
444
+ esac
445
+ done <<EOF
446
+ $findings
447
+ EOF
448
+ if [ "$data_ok" = "1" ]; then
449
+ printf '\nRead-only inspection commands and --help are allowed.\n'
450
+ else
451
+ printf '\nThe command lists could not be read from %s, so every cratis %s command is refused until they can.\n' "$error_file" "$group"
452
+ fi
453
+ cat <<EOF
454
+
455
+ The cratis-chronicle-cli-operations skill makes --yes the authorization boundary for a live Chronicle
456
+ store: a request to inspect or diagnose does not authorize a replay, a retry, clearing quarantine, job
457
+ control, a recommendation, or adding or removing users, applications or subscriptions.
458
+
459
+ Do not retry this, rephrase it, or work around the guard (another wrapper, a script file, a variable,
460
+ an alias, the terminal Workbench). Stop and report to the user:
461
+ 1. the exact command you intended to run;
462
+ 2. the target it resolves to: the context ('cratis context show'), or the --server /
463
+ CHRONICLE_CONNECTION_STRING it uses - never print credentials;
464
+ 3. what it changes and why that is needed, and how the change is undone;
465
+ then ask the user to run it themselves or to authorize it. A person who authorizes it sets
466
+ CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1 in the environment the agent harness was started from; setting it
467
+ inside the command has no effect.
468
+ EOF
469
+ } >&2
470
+ exit 2
@@ -0,0 +1,85 @@
1
+ {
2
+ "$comment": [
3
+ "Command classification for the Cratis PreToolUse Bash guard (cratis-guard-store-mutations.sh).",
4
+ "Data, not code: a consuming repository extends or replaces this without touching the script.",
5
+ " - drop a cratis-store-mutations.local.json next to this file to ADD entries (merged, never replaces)",
6
+ " - or point CRATIS_HOOKS_STORE_MUTATIONS at a replacement file",
7
+ "A command listed in \"mutating\" is blocked even when a local file also lists it in \"readOnly\", so a",
8
+ "local file can tighten the shipped lists but only a replacement file can loosen them.",
9
+ "",
10
+ "Scope: only the \"group\" command group is guarded. Every other cratis group (ai, arc, context, llm,",
11
+ "screenplay, prologue, completions) and every top-level command (init, new, render, run, update,",
12
+ "version, get-started, llm-context) is out of scope and always allowed.",
13
+ "",
14
+ "Fields:",
15
+ " verifiedAgainst the cratis CLI version whose `cratis llm-context` these lists were derived from",
16
+ " group the guarded command group",
17
+ " helpOptions options that print help instead of running; allowed wherever they appear",
18
+ " versionOptions options that print the version instead of running a command; allowed wherever they appear",
19
+ " flagOptions global/group options that take no value, skipped while reading the command path",
20
+ " valueOptions global/group options that take a value, skipped with that value",
21
+ " readOnly the allowlist: command paths below the group that only read state",
22
+ " command the command path, space-separated, e.g. \"observers list\"",
23
+ " reason why it is safe to run without a human's authorization",
24
+ " mutating known command paths that change server, client or external state",
25
+ " command the command path",
26
+ " effect what it changes; shown to the agent in the block message",
27
+ "",
28
+ "Anything under the group that is on neither list is blocked as unknown: the guard fails closed, so a",
29
+ "command added by a newer CLI is refused until someone classifies it here."
30
+ ],
31
+ "verifiedAgainst": "cratis 3.13.1",
32
+ "group": "chronicle",
33
+ "helpOptions": ["-h", "--help"],
34
+ "versionOptions": ["-v", "--version"],
35
+ "flagOptions": ["-q", "--quiet", "-y", "--yes", "--debug"],
36
+ "valueOptions": ["--server", "-o", "--output", "-e", "--event-store", "-n", "--namespace"],
37
+ "readOnly": [
38
+ { "command": "diagnose", "reason": "runs a health check and returns a diagnostic report" },
39
+ { "command": "auth status", "reason": "shows the cached token's expiry and identity" },
40
+ { "command": "applications list", "reason": "lists registered applications" },
41
+ { "command": "event-stores list", "reason": "lists event stores" },
42
+ { "command": "event-types list", "reason": "lists registered event types" },
43
+ { "command": "event-types show", "reason": "shows one event type registration and its schema" },
44
+ { "command": "events get", "reason": "reads events from an event sequence" },
45
+ { "command": "events tail", "reason": "returns the highest used sequence number" },
46
+ { "command": "failed-partitions list", "reason": "lists failed observer partitions" },
47
+ { "command": "failed-partitions show", "reason": "shows one failed partition and its error" },
48
+ { "command": "identities list", "reason": "lists known identities" },
49
+ { "command": "jobs get", "reason": "shows one background job" },
50
+ { "command": "jobs list", "reason": "lists background jobs" },
51
+ { "command": "namespaces list", "reason": "lists namespaces in an event store" },
52
+ { "command": "observers list", "reason": "lists observers" },
53
+ { "command": "observers show", "reason": "shows one observer's state" },
54
+ { "command": "projections list", "reason": "lists projection definitions" },
55
+ { "command": "projections show", "reason": "shows one projection definition" },
56
+ { "command": "read-models get", "reason": "reads one read model instance" },
57
+ { "command": "read-models instances", "reason": "lists read model instances" },
58
+ { "command": "read-models list", "reason": "lists read model definitions" },
59
+ { "command": "read-models occurrences", "reason": "lists a read model's replay history" },
60
+ { "command": "read-models snapshots", "reason": "reads snapshots of one read model instance" },
61
+ { "command": "recommendations list", "reason": "lists active recommendations" },
62
+ { "command": "subscriptions list", "reason": "lists event store subscriptions" },
63
+ { "command": "users list", "reason": "lists registered users" }
64
+ ],
65
+ "mutating": [
66
+ { "command": "observers replay", "effect": "replays an observer from the beginning of the event log, resetting all state it has built" },
67
+ { "command": "observers replay-partition", "effect": "replays one partition of an observer from the beginning, resetting that partition's state" },
68
+ { "command": "observers retry-partition", "effect": "retries a failed partition from the sequence number where it failed" },
69
+ { "command": "observers clear-quarantine", "effect": "clears an observer's quarantine so it resumes processing" },
70
+ { "command": "jobs stop", "effect": "stops a running background job" },
71
+ { "command": "jobs resume", "effect": "resumes a stopped or failed background job" },
72
+ { "command": "recommendations perform", "effect": "executes a server recommendation, such as a schema or replay maintenance action" },
73
+ { "command": "recommendations ignore", "effect": "marks a recommendation as ignored so it no longer appears" },
74
+ { "command": "subscriptions add", "effect": "adds an event store subscription to a target event store" },
75
+ { "command": "subscriptions remove", "effect": "removes an event store subscription from a target event store" },
76
+ { "command": "users add", "effect": "adds a user to the server's user store" },
77
+ { "command": "users remove", "effect": "removes a user from the server" },
78
+ { "command": "applications add", "effect": "registers an application with the server" },
79
+ { "command": "applications remove", "effect": "removes an application registration from the server" },
80
+ { "command": "login", "effect": "authenticates with a username and password and caches a token that later commands use" },
81
+ { "command": "logout", "effect": "clears the cached authentication token" },
82
+ { "command": "report-error", "effect": "opens a pre-filled GitHub issue in the browser" },
83
+ { "command": "workbench", "effect": "opens the interactive terminal Workbench, whose keys replay observers and retry or replay failed partitions; only a human at a terminal can drive it" }
84
+ ]
85
+ }
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  # Shared helpers for the Cratis enforcement hooks.
3
3
  #
4
- # Sourced by cratis-guard-writes.sh, cratis-pattern-scan.sh and cratis-quality-gate.sh.
4
+ # Sourced by cratis-guard-writes.sh, cratis-guard-store-mutations.sh, cratis-pattern-scan.sh and
5
+ # cratis-quality-gate.sh.
5
6
  # Portable: bash 3.2 (macOS system bash) and up, BSD + GNU userland. No GNU-only flags,
6
7
  # no `mapfile`/`readarray`, no associative arrays, no `eval`.
7
8
  set -euo pipefail
@@ -10,6 +10,15 @@
10
10
  "command": "\"$CLAUDE_PROJECT_DIR\"/.cratis/ai/hooks/scripts/cratis-guard-writes.sh"
11
11
  }
12
12
  ]
13
+ },
14
+ {
15
+ "matcher": "Bash",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "\"$CLAUDE_PROJECT_DIR\"/.cratis/ai/hooks/scripts/cratis-guard-store-mutations.sh"
20
+ }
21
+ ]
13
22
  }
14
23
  ],
15
24
  "PostToolUse": [
@@ -5,9 +5,9 @@ description: Audit hook files for correctness, portability, and enforcement cove
5
5
 
6
6
  # Audit Hooks
7
7
 
8
- Review `.cratis/ai/hooks/` and report whether hooks are:
8
+ Review `.cratis/ai/hooks/` — the write guard, store-mutation guard, pattern scan, and quality gate — and report whether hooks are:
9
9
 
10
- - enforcing the intended policy
10
+ - enforcing the intended policy (including the `cratis chronicle` read-only allowlist and its fail-closed behavior)
11
11
  - portable across environments
12
12
  - aligned with canonical source rules
13
13
  - using bash-first commands for script execution
@@ -152,6 +152,13 @@ These commands mutate the running store:
152
152
  - `chronicle users add`, `chronicle users remove`
153
153
  - `chronicle applications add`, `chronicle applications remove`
154
154
 
155
+ Where the Cratis hooks are installed, this boundary is enforced: a `PreToolUse` guard on shell
156
+ commands blocks every `cratis chronicle` command that is not on its read-only allowlist, unknown
157
+ commands and the interactive `workbench` included, with or without `--yes`. When it blocks, do not
158
+ retry or work around it; report the exact command, the target context or server, and what it
159
+ changes and why, and ask. A person who authorizes the mutation runs it, or sets
160
+ `CRATIS_HOOKS_ALLOW_STORE_MUTATIONS=1` in the environment the agent harness starts from.
161
+
155
162
  Before any of them:
156
163
 
157
164
  1. Name the exact server context, event store, namespace, observer, partition,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "3.2.1",
3
+ "version": "3.3.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [