@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.
- package/package/corpus/harnesses/pi/extensions/cratis-hooks/index.ts +49 -19
- package/package/corpus/hooks/README.md +56 -5
- package/package/corpus/hooks/scripts/cratis-guard-store-mutations.sh +470 -0
- package/package/corpus/hooks/scripts/cratis-store-mutations.json +85 -0
- package/package/corpus/hooks/scripts/hook-lib.sh +2 -1
- package/package/corpus/hooks/settings.template.json +9 -0
- package/package/corpus/prompts/audit-hooks.prompt.md +2 -2
- package/package/corpus/skills/cratis-chronicle-cli-operations/SKILL.md +7 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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 =
|
|
129
|
-
|
|
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
|
|
35
|
-
> extension under `../harnesses/pi/extensions
|
|
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
|
|
275
|
-
customises
|
|
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-
|
|
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,
|