@cratis/pi 7.100.0 → 7.101.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.
Files changed (47) hide show
  1. package/package/corpus/agents/backend-developer.md +1 -0
  2. package/package/corpus/agents/browser-operator.md +44 -0
  3. package/package/corpus/agents/code-reviewer.md +4 -0
  4. package/package/corpus/agents/coordinator.md +1 -0
  5. package/package/corpus/agents/frontend-developer.md +1 -0
  6. package/package/corpus/agents/orchestrator.md +1 -0
  7. package/package/corpus/agents/performance-reviewer.md +4 -0
  8. package/package/corpus/agents/planner.md +1 -0
  9. package/package/corpus/agents/repository-investigation-reviewer.md +4 -0
  10. package/package/corpus/agents/repository-investigator.md +1 -0
  11. package/package/corpus/agents/screenplay-modeler.md +1 -0
  12. package/package/corpus/agents/screenplay-renderer.md +1 -0
  13. package/package/corpus/agents/screenplay-reviewer.md +4 -0
  14. package/package/corpus/agents/security-reviewer.md +4 -0
  15. package/package/corpus/agents/slice-implementer.md +1 -0
  16. package/package/corpus/agents/spec-writer.md +1 -0
  17. package/package/corpus/harnesses/copilot/instructions/browser.instructions.md +46 -0
  18. package/package/corpus/harnesses/copilot/instructions/local-work-artifacts.instructions.md +3 -0
  19. package/package/corpus/harnesses/copilot/instructions/review-reports.instructions.md +11 -0
  20. package/package/corpus/harnesses/copilot/instructions/secrets.instructions.md +35 -0
  21. package/package/corpus/harnesses/pi/agents/backend-developer.md +1 -0
  22. package/package/corpus/harnesses/pi/agents/browser-operator.md +41 -0
  23. package/package/corpus/harnesses/pi/agents/code-reviewer.md +4 -0
  24. package/package/corpus/harnesses/pi/agents/coordinator.md +1 -0
  25. package/package/corpus/harnesses/pi/agents/frontend-developer.md +1 -0
  26. package/package/corpus/harnesses/pi/agents/orchestrator.md +1 -0
  27. package/package/corpus/harnesses/pi/agents/performance-reviewer.md +4 -0
  28. package/package/corpus/harnesses/pi/agents/planner.md +1 -0
  29. package/package/corpus/harnesses/pi/agents/repository-investigation-reviewer.md +4 -0
  30. package/package/corpus/harnesses/pi/agents/repository-investigator.md +1 -0
  31. package/package/corpus/harnesses/pi/agents/screenplay-modeler.md +1 -0
  32. package/package/corpus/harnesses/pi/agents/screenplay-renderer.md +1 -0
  33. package/package/corpus/harnesses/pi/agents/screenplay-reviewer.md +4 -0
  34. package/package/corpus/harnesses/pi/agents/security-reviewer.md +4 -0
  35. package/package/corpus/harnesses/pi/agents/slice-implementer.md +1 -0
  36. package/package/corpus/harnesses/pi/agents/spec-writer.md +1 -0
  37. package/package/corpus/harnesses/pi/extensions/cratis-hooks/index.ts +19 -6
  38. package/package/corpus/harnesses/pi/extensions/cratis-hooks/quality-gate.ts +10 -1
  39. package/package/corpus/hooks/README.md +8 -0
  40. package/package/corpus/hooks/scripts/cratis-guard-secrets.sh +458 -0
  41. package/package/corpus/hooks/settings.template.json +4 -0
  42. package/package/corpus/rules/browser.md +45 -0
  43. package/package/corpus/rules/general.md +2 -0
  44. package/package/corpus/rules/local-work-artifacts.md +3 -0
  45. package/package/corpus/rules/review-reports.md +11 -0
  46. package/package/corpus/rules/secrets.md +34 -0
  47. package/package.json +1 -1
@@ -11,6 +11,7 @@ Three layers:
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
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 |
14
+ | Hard block | `PreToolUse` on `Bash` | `scripts/cratis-guard-secrets.sh` | zero; parses only commands that mention a credential-bearing tool or file | exits **2** — a command that prints credentials (full `kubectl` specs and secrets, `env`/`printenv`, `docker inspect`, secret-manager reads, `.env`/kubeconfig/key files) does not run unless it projects a field; see [`secrets.md`](../rules/secrets.md) |
14
15
  | Hard block | `PreToolUse` on `Bash` | `scripts/cratis-guard-pr-body.sh` | fetches shared rules only for `gh pr create/edit` | exits **2** for inline bodies, invalid notes or conflicting release labels; warns and allows when offline with no cache |
15
16
  | Cleanup of delivered work | `PostToolUse` on `Bash` (`gh pr merge`, `git pull/merge/worktree`) and `SessionStart` (Pi: `tool_result` and `session_start`) | `scripts/cratis-cleanup-worktrees.sh --hook` | zero when the command is unrelated | never blocks; removes delivered worktrees and branches, reports what it kept |
16
17
  | Quality gate (Claude) | `Stop` | `scripts/cratis-quality-gate.sh` | one build/test run, only when relevant files changed | exits **2** — the turn does not end |
@@ -674,6 +675,7 @@ Each is an explicit, auditable opt-out — none of them is a default.
674
675
  | `CRATIS_HOOKS_PATTERNS=<path>` | replaces the pattern file |
675
676
  | `CRATIS_HOOKS_GATES=<path>` | replaces the gate file (the project override still merges over it) |
676
677
  | `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 |
678
+ | `CRATIS_HOOKS_ALLOW_SECRET_READS=1` | allows commands the secrets guard blocks; set by the person who authorized the read, in the environment the harness was started from — an assignment inside the command is ignored |
677
679
  | `CRATIS_HOOKS_STORE_MUTATIONS=<path>` | replaces the store-mutation guard's command lists (the `.local.json` beside the script still extends them) |
678
680
  | `CRATIS_HOOKS_SUBPATH_REPORT=1` | prints every `@cratis/*` subpath reference and how it resolved, not only the failures |
679
681
  | `CRATIS_HOOKS_IMPORT_REPORT=1` | prints every `@cratis/*` named import binding and how it resolved, not only the failures |
@@ -727,6 +729,12 @@ jq -nc '{tool_name:"Bash", tool_input:{command:"cratis chronicle observers repla
727
729
  jq -nc '{tool_name:"Bash", tool_input:{command:"cratis chronicle observers list -o plain"}}' \
728
730
  | bash .cratis/ai/hooks/scripts/cratis-guard-store-mutations.sh; echo "exit=$?"
729
731
 
732
+ # Secrets guard, both directions: expect exit 2, then exit 0 for the projected neighbor
733
+ jq -nc '{tool_name:"Bash", tool_input:{command:"kubectl get pods -o json"}}' \
734
+ | bash .cratis/ai/hooks/scripts/cratis-guard-secrets.sh; echo "exit=$?"
735
+ jq -nc '{tool_name:"Bash", tool_input:{command:"kubectl get pods -o name"}}' \
736
+ | bash .cratis/ai/hooks/scripts/cratis-guard-secrets.sh; echo "exit=$?"
737
+
730
738
  # Quality gate — show the dispatch plan without running anything
731
739
  jq -nc '{session_id:"t", cwd:"'"$PWD"'", stop_hook_active:false}' \
732
740
  | CRATIS_HOOKS_GATE_DRYRUN=1 bash .cratis/ai/hooks/scripts/cratis-quality-gate.sh
@@ -0,0 +1,458 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook (Bash) — hard block on shell commands that print credentials into the transcript.
3
+ #
4
+ # secrets.md says a credential must never enter an agent transcript, and lists the command classes that
5
+ # print one: full Kubernetes specs and secrets, the process environment, container inspection, secret-manager
6
+ # reads and credential files. This guard is what enforces it: every simple command in the shell command is
7
+ # found (quotes, $( ), backticks, subshells, pipelines, here-documents, `bash -c`, `eval`, `kubectl exec ... --`
8
+ # and `docker exec`, and the wrappers sudo/env/timeout/xargs/rtk are looked through) and classified. A command
9
+ # that only mentions a word inside an argument (echo, rg, grep, git commit -m) is never a finding.
10
+ # Exit 2 blocks the tool call and sends stderr back to the model; every other command exits 0.
11
+ #
12
+ # What it sees is the command text. A credential reached through a variable, an alias, a function, a script
13
+ # file or a pipe into a shell is invisible to it; a projection it cannot see through (`-o jsonpath` that dumps
14
+ # `{.}`) is trusted. See .cratis/ai/hooks/README.md.
15
+ #
16
+ # Escape hatch for a human who authorized the read — set in the environment the agent harness was started
17
+ # from, never inside the command (an assignment in the command is ignored):
18
+ # CRATIS_HOOKS_ALLOW_SECRET_READS=1
19
+ set -euo pipefail
20
+
21
+ # SCRIPTDIR, not a path relative to the caller: shellcheck resolves a plain relative `source=`
22
+ # against the current working directory, and these hooks are linted from wherever CI happens to run.
23
+ # shellcheck source=SCRIPTDIR/hook-lib.sh
24
+ . "$(dirname "${BASH_SOURCE[0]}")/hook-lib.sh"
25
+
26
+ [ "${CRATIS_HOOKS_ALLOW_SECRET_READS:-0}" = "1" ] && exit 0
27
+
28
+ input="$(hook_read_stdin)"
29
+ [ -n "$input" ] || exit 0
30
+ hook_have jq || exit 0
31
+
32
+ command_text="$(hook_json "$input" '.tool_input.command')"
33
+ [ -n "$command_text" ] || exit 0
34
+
35
+ # Cheap exit for the overwhelming majority of shell commands, which mention none of these words.
36
+ case "$(printf '%s' "$command_text" | tr '[:upper:]' '[:lower:]')" in
37
+ *kubectl*|*printenv*|*env*|*docker*|*podman*|*"gh "*|*vault*|*pulumi*|*aws*|*"az "*|*gcloud*|*kubeconfig*|*.kube*|\
38
+ *export*|*declare*|*typeset*|*id_rsa*|*id_ed25519*|*id_ecdsa*|*id_dsa*|*.pem*|*.p12*|*.pfx*|*token*|*.netrc*|*.npmrc*|\
39
+ *credentials*|*.pgpass*|*secrets*) ;;
40
+ *) exit 0 ;;
41
+ esac
42
+
43
+ IFS= read -r -d '' awk_program <<'AWK' || true
44
+ function report(what, alt) {
45
+ if (what in seen) return
46
+ seen[what] = 1
47
+ printf "%s\t%s\n", what, alt
48
+ }
49
+ function enqueue(text) {
50
+ if (text == "" || queued >= 64) return
51
+ queue[++queued] = text
52
+ }
53
+ function base(word) { word = tolower(word); sub(/.*\//, "", word); return word }
54
+
55
+ # ── tokenizer: splits text into simple commands, queueing nested command text ──
56
+ function flush_word() {
57
+ if (inword) W[++nw] = word
58
+ word = ""; inword = 0
59
+ }
60
+ function end_cmd() {
61
+ flush_word()
62
+ if (nw > 0) analyze(W, 1, nw)
63
+ split("", W); nw = 0
64
+ }
65
+ function cmdsub(s, i, n, start, depth, c, j) {
66
+ start = i; depth = 1
67
+ while (i <= n) {
68
+ c = substr(s, i, 1)
69
+ if (c == "\\") { i += 2; continue }
70
+ if (c == "\047") { j = index(substr(s, i + 1), "\047"); if (j == 0) { i = n + 1; break } i += j + 1; continue }
71
+ if (c == "\"") {
72
+ i++
73
+ while (i <= n && substr(s, i, 1) != "\"") { if (substr(s, i, 1) == "\\") i++; i++ }
74
+ i++; continue
75
+ }
76
+ if (c == "(") depth++
77
+ else if (c == ")") { depth--; if (depth == 0) break }
78
+ i++
79
+ }
80
+ enqueue(substr(s, start, i - start))
81
+ return i + 1
82
+ }
83
+ function read_heredocs(s, i, n, h, e, line, cmp, body) {
84
+ for (h = 1; h <= nheredoc; h++) {
85
+ body = ""
86
+ while (i <= n) {
87
+ e = index(substr(s, i), "\n")
88
+ if (e == 0) { line = substr(s, i); i = n + 1 } else { line = substr(s, i, e - 1); i += e }
89
+ cmp = line
90
+ if (HDDASH[h]) sub(/^\t+/, "", cmp)
91
+ if (cmp == HD[h]) break
92
+ body = body line "\n"
93
+ }
94
+ if (hd_shell) enqueue(body)
95
+ }
96
+ nheredoc = 0; hd_shell = 0
97
+ return i
98
+ }
99
+ function tokenize(s, i, n, c, c2, j, dash, delim) {
100
+ n = length(s); i = 1; nw = 0; word = ""; inword = 0; nheredoc = 0; hd_shell = 0
101
+ split("", W)
102
+ while (i <= n) {
103
+ c = substr(s, i, 1)
104
+ if (c == "\\") {
105
+ c2 = substr(s, i + 1, 1)
106
+ if (c2 == "\n") { i += 2; continue }
107
+ word = word c2; inword = 1; i += 2; continue
108
+ }
109
+ if (c == "\047") {
110
+ inword = 1
111
+ j = index(substr(s, i + 1), "\047")
112
+ if (j == 0) { word = word substr(s, i + 1); i = n + 1; continue }
113
+ word = word substr(s, i + 1, j - 1); i += j + 1; continue
114
+ }
115
+ if (c == "\"") {
116
+ inword = 1; i++
117
+ while (i <= n) {
118
+ c = substr(s, i, 1)
119
+ if (c == "\\") { word = word substr(s, i + 1, 1); i += 2; continue }
120
+ if (c == "\"") { i++; break }
121
+ if (c == "$" && substr(s, i + 1, 1) == "(") { i = cmdsub(s, i + 2, n); word = word "x"; continue }
122
+ if (c == "`") {
123
+ j = index(substr(s, i + 1), "`")
124
+ if (j == 0) { i = n + 1; break }
125
+ enqueue(substr(s, i + 1, j - 1)); i += j + 1; word = word "x"; continue
126
+ }
127
+ word = word c; i++
128
+ }
129
+ continue
130
+ }
131
+ if (c == "$" && substr(s, i + 1, 1) == "(") { inword = 1; i = cmdsub(s, i + 2, n); word = word "x"; continue }
132
+ if (c == "`") {
133
+ inword = 1
134
+ j = index(substr(s, i + 1), "`")
135
+ if (j == 0) { i = n + 1; continue }
136
+ enqueue(substr(s, i + 1, j - 1)); i += j + 1; word = word "x"; continue
137
+ }
138
+ if (c == "#" && !inword) {
139
+ j = index(substr(s, i), "\n")
140
+ if (j == 0) { i = n + 1 } else { i += j - 1 }
141
+ continue
142
+ }
143
+ if (c == " " || c == "\t" || c == "\r") { flush_word(); i++; continue }
144
+ if (c == "\n") {
145
+ end_cmd(); i++
146
+ if (nheredoc > 0) i = read_heredocs(s, i, n)
147
+ continue
148
+ }
149
+ if (c == ";" || c == "&" || c == "|" || c == "(" || c == ")") { end_cmd(); i++; continue }
150
+ if (substr(s, i, 2) == "<<" && substr(s, i, 3) != "<<<") {
151
+ flush_word(); i += 2; dash = 0
152
+ if (substr(s, i, 1) == "-") { dash = 1; i++ }
153
+ while (substr(s, i, 1) == " " || substr(s, i, 1) == "\t") i++
154
+ delim = ""
155
+ while (i <= n) {
156
+ c = substr(s, i, 1)
157
+ if (c ~ /[ \t\n;&|()<>]/) break
158
+ if (c != "\047" && c != "\"" && c != "\\") delim = delim c
159
+ i++
160
+ }
161
+ nheredoc++; HD[nheredoc] = delim; HDDASH[nheredoc] = dash
162
+ continue
163
+ }
164
+ word = word c; inword = 1; i++
165
+ }
166
+ end_cmd()
167
+ }
168
+
169
+ # ── option parsing shared by the CLIs: positionals P/PI, output format, -f/--format value, --raw ──
170
+ function kparse(A, s, n, shortvals, i, w, eq, key, val, f, rest) {
171
+ split("", P); split("", PI)
172
+ np = 0; outfmt = ""; fmtval = ""; dd = 0; raw = 0
173
+ for (i = s; i <= n; i++) {
174
+ w = A[i]
175
+ if (w == "--") { dd = i; break }
176
+ if (substr(w, 1, 2) == "--") {
177
+ eq = index(w, "="); key = w; val = ""
178
+ if (eq) { key = substr(w, 1, eq - 1); val = substr(w, eq + 1) }
179
+ if (key == "--output") { if (!eq) { i++; val = A[i] } outfmt = tolower(val) }
180
+ else if (key == "--format") { if (!eq) { i++; val = A[i] } fmtval = val }
181
+ else if (key == "--raw") raw = 1
182
+ else if (!eq && (key in LV)) i++
183
+ continue
184
+ }
185
+ if (substr(w, 1, 1) == "-" && length(w) > 1) {
186
+ f = substr(w, 2, 1); rest = substr(w, 3)
187
+ if (f == "o") {
188
+ if (rest == "") { i++; val = A[i] } else val = (substr(rest, 1, 1) == "=" ? substr(rest, 2) : rest)
189
+ outfmt = tolower(val)
190
+ } else if (f == "f" && index(shortvals, "f")) {
191
+ if (rest == "") { i++; val = A[i] } else val = (substr(rest, 1, 1) == "=" ? substr(rest, 2) : rest)
192
+ fmtval = val
193
+ } else if (rest == "" && f != "" && index(shortvals, f)) i++
194
+ continue
195
+ }
196
+ np++; P[np] = w; PI[np] = i
197
+ }
198
+ }
199
+ function skipopts(A, s, n, vals) {
200
+ while (s <= n && substr(A[s], 1, 1) == "-") {
201
+ if (length(A[s]) == 2 && index(vals, substr(A[s], 2, 1))) s += 2
202
+ else s++
203
+ }
204
+ return s
205
+ }
206
+ function is_assign(w) { return w ~ /^[A-Za-z_][A-Za-z0-9_]*=/ }
207
+
208
+ # ── file classification for the reader commands ──
209
+ function code_file(b) { return b ~ /\.(cs|ts|tsx|js|jsx|mjs|cjs|go|py|java|kt|rs|md)$/ }
210
+ # A design-token file is not a credential: a stylesheet, or a file under a design, theme or style directory, is
211
+ # fine unless it sits in a secrets or credentials directory. A bare token.json (an OAuth refresh-token file) is not.
212
+ function token_ok(l, b) {
213
+ if (l ~ /(^|\/)(secrets?|credentials?)\//) return 0
214
+ if (b ~ /\.(css|scss|less)$/) return 1
215
+ return l ~ /(^|\/)(design|design-tokens|themes?|styles?)\//
216
+ }
217
+ function sensitive_path(w, l, b) {
218
+ l = tolower(w); b = l; sub(/.*\//, "", b)
219
+ if (l ~ /(^|\/)\.kube\// || b == "kubeconfig" || b ~ /\.kubeconfig$/) return "a kubeconfig"
220
+ if (b ~ /^\.env/ || b ~ /\.env$/) { if (b ~ /\.(example|sample|template|dist|defaults?)$/) return ""; return "an .env file" }
221
+ if (b ~ /\.(pem|p12|pfx)$/) return "a key or certificate file"
222
+ if (b == "id_rsa" || b == "id_ed25519" || b == "id_ecdsa" || b == "id_dsa") return "a private key"
223
+ if (b ~ /(^|[._-])tokens?($|[._-])/ && !code_file(b) && !token_ok(l, b)) return "a token file"
224
+ if (b == ".netrc" || b == ".npmrc" || b == ".pgpass" || b == ".git-credentials") return "a credentials file"
225
+ if (b == "credentials" && l ~ /\.aws\//) return "the AWS credentials file"
226
+ if (l ~ /\.docker\/config\.json$/) return "the Docker registry credentials"
227
+ if (l ~ /\/proc\/[^\/]*\/environ$/) return "a process environment"
228
+ if (l ~ /^\/(var\/)?run\/secrets\//) return "mounted secrets"
229
+ return ""
230
+ }
231
+
232
+ # ── tools ──
233
+ function kube(A, s, n, verb, m, parts, k, r, rlist) {
234
+ kparse(A, s + 1, n, "nlcsfkLR")
235
+ verb = P[1]
236
+ if (verb == "exec") {
237
+ if (dd) analyze(A, dd + 1, n)
238
+ else if (np >= 3) analyze(A, PI[3], n)
239
+ return
240
+ }
241
+ if (verb == "config") {
242
+ if (P[2] == "view" && raw) report("kubectl config view --raw prints the cluster credentials", "kubectl config view (redacted), kubectl config current-context, or kubectl config get-contexts")
243
+ return
244
+ }
245
+ if (verb != "get" && verb != "describe") return
246
+ # `get pods,cm` and `get pod web` name one resource type; `get svc/web secret/db` names one per argument.
247
+ rlist = P[2]
248
+ if (index(P[2], "/")) for (k = 3; k <= np; k++) if (index(P[k], "/")) rlist = rlist "," P[k]
249
+ m = split(tolower(rlist), parts, ",")
250
+ for (k = 1; k <= m; k++) {
251
+ r = parts[k]; sub(/\/.*/, "", r); sub(/\..*/, "", r)
252
+ if (r == "secret" || r == "secrets") {
253
+ if (verb == "describe" || outfmt != "name")
254
+ report("kubectl " verb " secret prints secret data", "kubectl get secrets -o name to list them, or name the secret and key in your report instead of reading it")
255
+ } else if (r in SENS) {
256
+ if (verb == "describe")
257
+ report("kubectl describe " r " prints the full container spec, including inline credentials", "kubectl get " r " -o jsonpath=" SQ "{.items[*].spec.containers[*].image}" SQ ", -o custom-columns=NAME:.metadata.name,STATUS:.status.phase, -o name, or kubectl get events / kubectl logs; say which field you need")
258
+ else if (outfmt == "json" || outfmt == "yaml")
259
+ report("kubectl get " r " -o " outfmt " prints the full container spec, including inline credentials", "-o jsonpath=" SQ "{.items[*].spec.containers[*].image}" SQ ", -o custom-columns=NAME:.metadata.name,STATUS:.status.phase, -o name, -o wide, or the status fields you need")
260
+ }
261
+ }
262
+ }
263
+ function fmt_leaks(f) {
264
+ f = tolower(f)
265
+ return f ~ /env|json|\.args/ || f ~ /[{][{] *\.? *[}][}]/ || f ~ /\.config *[}][}]/
266
+ }
267
+ function dock(A, s, n, off, sub1, nxt, insp, k, listing) {
268
+ kparse(A, s + 1, n, "Hceuwlpfv")
269
+ off = (P[1] == "compose") ? 1 : 0
270
+ sub1 = P[1 + off]; nxt = P[2 + off]
271
+ insp = (sub1 == "inspect") || ((sub1 == "container" || sub1 == "image" || sub1 == "service") && nxt == "inspect")
272
+ if (insp && (fmtval == "" || fmt_leaks(fmtval)))
273
+ report("docker inspect prints the container environment, including credentials", "docker inspect --format " SQ "{{.State.Status}}" SQ " (or {{.Config.Image}}, {{.NetworkSettings.IPAddress}}), docker ps, or docker logs")
274
+ if (off && sub1 == "config") {
275
+ for (k = s + 1; k <= n; k++) if (A[k] ~ /^(--services|--volumes|--profiles|--images|--networks|--quiet|-q)$/) listing = 1
276
+ if (!listing) report("docker compose config prints the resolved environment, including credentials", "docker compose config --services, or read the compose file and name the variables instead of printing their values")
277
+ }
278
+ if (sub1 == "exec" && np >= 3 + off) analyze(A, PI[3 + off], n)
279
+ }
280
+ function gh(A, s, n, i, w, mutating, k) {
281
+ kparse(A, s + 1, n, "XHfFqtR")
282
+ if (P[1] == "secret") {
283
+ if (P[2] != "set" && P[2] != "delete" && P[2] != "remove")
284
+ report("gh secret " P[2] " reads the repository secret store", "gh secret set NAME (writes nothing to the output); refer to secrets by name only")
285
+ return
286
+ }
287
+ if (P[1] == "auth") {
288
+ if (P[2] == "token") report("gh auth token prints the GitHub token", "gh auth status (without --show-token) to confirm you are signed in")
289
+ else if (P[2] == "status")
290
+ for (i = s + 1; i <= n; i++) if (A[i] == "-t" || A[i] == "--show-token") report("gh auth status --show-token prints the GitHub token", "gh auth status without --show-token")
291
+ return
292
+ }
293
+ if (P[1] == "api") {
294
+ mutating = 0
295
+ for (i = s + 1; i < n; i++) if (A[i] == "-X" || A[i] == "--method") { w = toupper(A[i + 1]); if (w == "PUT" || w == "PATCH" || w == "DELETE") mutating = 1 }
296
+ if (!mutating)
297
+ for (k = 2; k <= np; k++)
298
+ if (tolower(P[k]) ~ /(^|\/)secrets([\/?]|$)/) report("gh api " P[k] " reads the repository secret store", "gh secret set NAME for writes; name the secret instead of reading it")
299
+ }
300
+ }
301
+ function vault(A, s, n) {
302
+ kparse(A, s + 1, n, "")
303
+ if (P[1] == "read" || (P[1] == "kv" && (P[2] == "get" || P[2] == "read")))
304
+ report("vault " P[1] (P[1] == "kv" ? " " P[2] : "") " prints secret values", "vault kv list or vault kv metadata get for the key names; name the path and key in your report")
305
+ }
306
+ function aws(A, s, n, i, decrypt) {
307
+ kparse(A, s + 1, n, "")
308
+ if (P[1] == "secretsmanager" && P[2] == "get-secret-value") report("aws secretsmanager get-secret-value prints the secret", "aws secretsmanager list-secrets or describe-secret (metadata only)")
309
+ if (P[1] == "configure" && P[2] == "export-credentials") report("aws configure export-credentials prints the credentials", "aws sts get-caller-identity to confirm who you are")
310
+ if (P[1] == "ssm" && P[2] ~ /^get-parameter/) {
311
+ decrypt = 0
312
+ for (i = s + 1; i <= n; i++) if (A[i] == "--with-decryption") decrypt = 1
313
+ if (decrypt) report("aws ssm " P[2] " --with-decryption prints decrypted parameters", "aws ssm describe-parameters, or get-parameter without --with-decryption")
314
+ }
315
+ }
316
+ function azure(A, s, n) {
317
+ kparse(A, s + 1, n, "n")
318
+ if (P[1] == "keyvault" && P[2] == "secret" && (P[3] == "show" || P[3] == "download")) report("az keyvault secret " P[3] " prints the secret", "az keyvault secret list, or az keyvault secret show-deleted metadata only; name the secret in your report")
319
+ if (P[1] == "account" && P[2] == "get-access-token") report("az account get-access-token prints a bearer token", "az account show")
320
+ }
321
+ function gcloud(A, s, n, k) {
322
+ kparse(A, s + 1, n, "")
323
+ if (P[1] == "secrets" && P[2] == "versions" && P[3] == "access") report("gcloud secrets versions access prints the secret", "gcloud secrets versions list, or gcloud secrets describe (metadata only)")
324
+ if (P[1] == "auth") for (k = 2; k <= np; k++) if (P[k] ~ /^print-(access|identity)-token$/) report("gcloud auth " P[k] " prints a token", "gcloud auth list")
325
+ }
326
+ function reader(A, s, n, b, k, w, mode, havepat, need, afterdd, kind, valued, valued2) {
327
+ b = base(A[s]); mode = ""; valued = " "; valued2 = " "
328
+ if (b ~ /^(grep|egrep|fgrep|rg|ag)$/) { mode = "pattern"; valued = " -m -A -B -C -g -t -T -d -D --glob --type --type-not --max-count --include --exclude --exclude-dir --context --after-context --before-context --max-depth --threads " }
329
+ else if (b ~ /^(sed|awk|gawk|jq|yq)$/) { mode = "script"; valued = " -v -F --indent "; valued2 = " --arg --argjson --slurpfile --rawfile " }
330
+ # The first operand is the pattern or script unless -e/-f/--regexp/--file/--expression supplies it; only the rest are files.
331
+ havepat = 0
332
+ if (mode != "") for (k = s + 1; k <= n; k++) if (A[k] ~ /^-[a-zA-Z]*[ef]$/ || A[k] ~ /^--(regexp|file|expression|from-file)(=|$)/) havepat = 1
333
+ need = (mode != "" && !havepat); afterdd = 0
334
+ for (k = s + 1; k <= n; k++) {
335
+ w = A[k]
336
+ if (w == "--" && !afterdd) { afterdd = 1; continue }
337
+ if (!afterdd && substr(w, 1, 1) == "-" && length(w) > 1) {
338
+ if (mode != "" && (w ~ /^-[a-zA-Z]*[ef]$/ || w ~ /^--(regexp|file|expression|from-file)$/ || index(valued, " " w " "))) k++
339
+ else if (index(valued2, " " w " ")) k += 2
340
+ continue
341
+ }
342
+ if (need) { need = 0; continue }
343
+ kind = sensitive_path(w)
344
+ if (kind != "") report(b " of " kind " prints credentials", "name the file and the key you need in your report; ask the user to confirm a value instead of printing it; for an env template read the .env.example")
345
+ }
346
+ }
347
+
348
+ function analyze(A, s, n, w, b, k, names, p) {
349
+ while (s <= n) {
350
+ w = A[s]; b = base(w)
351
+ if (is_assign(w)) { s++; continue }
352
+ if (w ~ /^[0-9]*[<>]/) { s += (w ~ /^[0-9]*[<>]+&?-?$/) ? 2 : 1; continue }
353
+ if (b in KW) { s++; continue }
354
+ if (b == "command" || b == "builtin") { s++; if (s <= n && (A[s] == "-v" || A[s] == "-V")) return; continue }
355
+ if (b == "sudo" || b == "doas") { s = skipopts(A, s + 1, n, "ugChprtUD"); continue }
356
+ if (b == "xargs") { s = skipopts(A, s + 1, n, "IniPLdsEa"); continue }
357
+ if (b == "timeout") { s = skipopts(A, s + 1, n, "ks") + 1; continue }
358
+ if (b == "nice" || b == "watch") { s = skipopts(A, s + 1, n, "n"); continue }
359
+ if (b == "nohup" || b == "time" || b == "exec" || b == "setsid") { s = skipopts(A, s + 1, n, ""); continue }
360
+ if (b == "rtk") { s++; if (s <= n && base(A[s]) == "proxy") s++; continue }
361
+ if (b == "env") {
362
+ s++
363
+ while (s <= n) {
364
+ w = A[s]
365
+ if (is_assign(w)) s++
366
+ else if (w == "-u" || w == "-C" || w == "-S" || w == "--unset" || w == "--chdir" || w == "--split-string") s += 2
367
+ else if (substr(w, 1, 1) == "-") s++
368
+ else break
369
+ }
370
+ if (s > n) { report("env with no command prints the whole environment, including credentials (env | ... is blocked too)", "printenv NAME for one variable that is not a credential, or [ -n \"${NAME:-}\" ] && echo set to test that a variable exists"); return }
371
+ continue
372
+ }
373
+ break
374
+ }
375
+ if (s > n) return
376
+ b = base(A[s])
377
+ if (b == "bash" || b == "sh" || b == "zsh" || b == "dash" || b == "ksh") {
378
+ for (k = s + 1; k <= n; k++)
379
+ if (A[k] ~ /^-[a-zA-Z]*c[a-zA-Z]*$/) { enqueue(A[k + 1]); return }
380
+ if (nheredoc > 0) hd_shell = 1
381
+ return
382
+ }
383
+ if (b == "eval") { p = ""; for (k = s + 1; k <= n; k++) p = p " " A[k]; enqueue(p); return }
384
+ if (b == "kubectl" || b == "oc") return kube(A, s, n)
385
+ if (b == "docker" || b == "podman") return dock(A, s, n)
386
+ if (b == "docker-compose") { split("", T); T[1] = "docker"; T[2] = "compose"; for (k = s + 1; k <= n; k++) T[k - s + 2] = A[k]; return dock(T, 1, n - s + 2) }
387
+ if (b == "gh") return gh(A, s, n)
388
+ if (b == "vault") return vault(A, s, n)
389
+ if (b == "aws") return aws(A, s, n)
390
+ if (b == "az") return azure(A, s, n)
391
+ if (b == "gcloud") return gcloud(A, s, n)
392
+ if (b == "pulumi") {
393
+ for (k = s + 1; k <= n; k++) if (A[k] ~ /^--show-secrets/) report("pulumi --show-secrets prints decrypted secrets", "pulumi config / pulumi stack output without --show-secrets (secrets stay masked)")
394
+ return
395
+ }
396
+ if (b == "printenv") {
397
+ names = 0
398
+ for (k = s + 1; k <= n; k++) {
399
+ if (substr(A[k], 1, 1) == "-") continue
400
+ names++
401
+ if (tolower(A[k]) ~ /token|secret|key|pass|pwd|credential|auth|cert|conn|dsn/) report("printenv " A[k] " prints a credential", "[ -n \"${" A[k] ":-}\" ] && echo set, which confirms it exists without printing it")
402
+ }
403
+ if (names == 0) report("printenv with no variable name prints the whole environment, including credentials", "printenv with the one non-credential variable you need, e.g. printenv HOME")
404
+ return
405
+ }
406
+ if (b == "export") {
407
+ names = 0; p = 0
408
+ for (k = s + 1; k <= n; k++) { if (A[k] == "-p") p = 1; else if (substr(A[k], 1, 1) != "-") names++ }
409
+ if (p || names == 0) report("export with no variable name prints the whole environment, including credentials", "printenv with the one non-credential variable you need")
410
+ return
411
+ }
412
+ if (b == "declare" || b == "typeset") {
413
+ names = 0; p = 0
414
+ for (k = s + 1; k <= n; k++) { if (A[k] ~ /^-[a-zA-Z]*[px][a-zA-Z]*$/) p = 1; else if (substr(A[k], 1, 1) != "-") names++ }
415
+ if (p && names == 0) report(b " -p/-x with no variable name prints the whole environment, including credentials", "printenv with the one non-credential variable you need")
416
+ return
417
+ }
418
+ if (b ~ /^(cat|tac|nl|less|more|head|tail|bat|batcat|strings|xxd|od|hexdump|cut|sort|uniq|base64|grep|egrep|fgrep|rg|ag|sed|awk|gawk|jq|yq)$/) reader(A, s, n)
419
+ }
420
+
421
+ BEGIN {
422
+ SQ = "\047"
423
+ split("namespace context kubeconfig server cluster user as as-group selector field-selector container label-columns sort-by request-timeout filename kustomize template chunk-size token env env-file workdir host config project-name file profile detach-keys log-level method header field raw-field jq repo hostname input region query endpoint-url vault-name subscription resource-group name id version project secret account", L, " ")
424
+ for (i in L) LV["--" L[i]] = 1
425
+ split("! { } if then else elif do while until", L, " ")
426
+ for (i in L) KW[L[i]] = 1
427
+ split("pods po pod deployments deployment deploy statefulsets statefulset sts daemonsets daemonset ds jobs job cronjobs cronjob cj configmaps configmap cm replicasets replicaset rs replicationcontrollers rc all", L, " ")
428
+ for (i in L) SENS[L[i]] = 1
429
+ enqueue(ENVIRON["GS_COMMAND"])
430
+ while (qi < queued) { qi++; tokenize(queue[qi]) }
431
+ }
432
+ AWK
433
+
434
+ findings="$(GS_COMMAND="$command_text" LC_ALL=C awk "$awk_program" </dev/null)"
435
+ [ -n "$findings" ] || exit 0
436
+
437
+ {
438
+ while IFS=$'\t' read -r what alternative; do
439
+ [ -n "$what" ] || continue
440
+ printf 'BLOCKED by cratis-guard-secrets: %s. Use instead: %s.\n' "$what" "$alternative"
441
+ done <<EOF
442
+ $findings
443
+ EOF
444
+ cat <<'EOF'
445
+
446
+ secrets.md: a credential must never enter an agent transcript, and a transcript is saved, summarized and
447
+ shared. Ask for the field you need (a jsonpath, custom-columns, -o name, a status field) and say which one;
448
+ refer to a secret by name (secret/key, environment variable) in notes, briefs and messages, never by value.
449
+
450
+ If a value has already appeared in output: stop, do not copy it onward, report "SECURITY INCIDENT" with the
451
+ tool-call location and the credential kind, and ask for rotation.
452
+
453
+ Do not retry this, rephrase it, or work around the guard (another wrapper, a script file, a variable, an
454
+ alias). A person who authorized the read sets CRATIS_HOOKS_ALLOW_SECRET_READS=1 in the environment the agent
455
+ harness was started from; setting it inside the command has no effect.
456
+ EOF
457
+ } >&2
458
+ exit 2
@@ -18,6 +18,10 @@
18
18
  "type": "command",
19
19
  "command": "bash \"$CLAUDE_PROJECT_DIR\"/.cratis/ai/hooks/scripts/cratis-guard-store-mutations.sh"
20
20
  },
21
+ {
22
+ "type": "command",
23
+ "command": "bash \"$CLAUDE_PROJECT_DIR\"/.cratis/ai/hooks/scripts/cratis-guard-secrets.sh"
24
+ },
21
25
  {
22
26
  "type": "command",
23
27
  "command": "bash \"$CLAUDE_PROJECT_DIR\"/.cratis/ai/hooks/scripts/cratis-guard-pr-body.sh"
@@ -0,0 +1,45 @@
1
+ ---
2
+ applyTo: "**/*"
3
+ ---
4
+
5
+ # Browser use
6
+
7
+ Browser access is a capability, not authority (see
8
+ [`capability-is-not-authority.md`](./capability-is-not-authority.md)). Every line is tagged
9
+ **[contract]** (binding) or **[convention]** (the house default) per the Three Levels of
10
+ Authority in [`general.md`](./general.md). Read this before any browser call.
11
+
12
+ ## Permit and handoff
13
+
14
+ - **[contract] Production is read-only.** Write to a production site only when the user's
15
+ request or an applicable policy names that exact action. A configured browser tool is not
16
+ a permit.
17
+ - **[contract] One production tab per session.** No parallel polling or parallel findings on
18
+ production. Run the health check first, close idle browsers, and look for stale tabs from
19
+ other sessions before opening one.
20
+ - **[convention] Prefer staging or a local copy** for interactive and visual smoke runs.
21
+ - **[contract] Only the `browser-operator` agent receives browser tools.** Hand it the URL,
22
+ scope, permit and artifact directory. The main session does not do browser work itself
23
+ unless no such agent is available. No other agent gets browser tools.
24
+ - **[contract] Stop for a human on sign-in, 2FA or CAPTCHA.** Record
25
+ `UI UNVERIFIED — needs human sign-in` and continue the other lanes. Do not block on it.
26
+
27
+ ## Artifacts and uploads
28
+
29
+ - **[contract] Write artifacts and stage uploads only under `.ai-work/<task>/browser/`**
30
+ (see [`local-work-artifacts.md`](./local-work-artifacts.md)). If a path guard blocks you,
31
+ stop and report. Never monkey-patch DOM or file APIs to get around it.
32
+ - **[contract] No secrets or tokens in artifacts, URLs or logs.**
33
+
34
+ ## Evidence
35
+
36
+ - **[contract] A UI verification records:** URL, build or version, screenshot path under
37
+ `.ai-work/`, correlation id, timestamp and environment. Without these it is unverified.
38
+
39
+ ## Headless production-smoke checklist
40
+
41
+ - **[convention]** Port-forward to the service.
42
+ - **[convention]** Front it with a GET-only proxy that injects the required headers.
43
+ - **[convention]** Drive it from a fresh headless browser with no persistent profile.
44
+ - **[convention]** Give any signed-in profile a single owner.
45
+ - **[convention]** Tear down the browser, proxy and port-forward when done.
@@ -146,7 +146,9 @@ A claim is only as good as the signal behind it — a build result, a test run,
146
146
  | MongoDB queries — native driver API over LINQ; paging and sorting belong to Arc | `mongodb.md` |
147
147
  | Before writing a commit message, committing, pushing or opening/editing a PR: read both | `git-commits.md`, `pull-requests.md` |
148
148
  | Reading, citing and superseding a decision record | the **cratis-engineering-decision-record** skill |
149
+ | Before any browser call: production permit, handoff, artifact paths, evidence | `browser.md` |
149
150
  | Whether you are allowed to do the thing you are able to do | `capability-is-not-authority.md` |
151
+ | Before running a command that could print a credential, or when a secret value appears in output | `secrets.md` |
150
152
  | What must stop and ask a human | `capability-is-not-authority.md` (absent authority for a consequential effect: stop and ask) |
151
153
  | What counts as evidence that something works | `verification-discipline.md` |
152
154
  | Before writing a session note, plan or handover: where they may live (`.ai-work/` only) | `local-work-artifacts.md` |
@@ -53,6 +53,9 @@ dumps, and similar coordination files. These are **work records, not documentati
53
53
  worktree and branch behind, which is why the script exists. If the branch must outlive the
54
54
  task unmerged, leave the worktree and say so in the handoff. Never `--force` a removal; a
55
55
  refusal means look, not push harder.
56
+ - **Scan for secrets before archiving or sharing.** Before anything from `.ai-work/` is archived,
57
+ attached or shared, run `gitleaks detect --no-git --source <dir>` (or the repository's equivalent)
58
+ and redact every finding. See [`secrets.md`](./secrets.md).
56
59
  - **A decision log is not a work record.** A decision — a durable choice with a
57
60
  decider and a date — is documentation: it lives in **`decisions/`** (or the
58
61
  repository's documented decisions folder, such as `.cratis/ai.json` `process.decisions.path`) and is reviewed like any other
@@ -65,3 +65,14 @@ scope limits when combining reports.
65
65
  When a workflow requires a typed review envelope, preserve its schema and express
66
66
  these sections, verdict meanings, and scope limits through its existing fields.
67
67
  Do not append a competing Markdown report or invent envelope fields.
68
+
69
+ ## Reviewer independence
70
+
71
+ - **[contract]** Run a reviewer on a different provider family than the author's model
72
+ where the harness can select one. The reviewer agents declare
73
+ `reviewIndependence: different-provider` and the `reviewer` model tier.
74
+ - **[contract]** Where the harness cannot select a provider, state `single-provider` in the
75
+ report header and on the Not examined line.
76
+ - **[contract]** A provider or tool error makes the review **Incomplete**. Fall back once
77
+ to the other family. Never report a failed verdict, or Approved, because the reviewer
78
+ could not run.
@@ -0,0 +1,34 @@
1
+ ---
2
+ applyTo: "**/*"
3
+ ---
4
+
5
+ # Secrets never enter agent transcripts
6
+
7
+ A transcript is saved, summarized and shared, so a credential that is printed once is leaked.
8
+ Every line is tagged **[contract]** (binding) per the Three Levels of Authority in
9
+ [`general.md`](./general.md). The `cratis-guard-secrets` hook blocks the commands below
10
+ (see [`.cratis/ai/hooks/README.md`](../hooks/README.md)); the rule holds where no hook runs.
11
+
12
+ ## Commands
13
+
14
+ - **[contract] Never run a command whose output contains credentials:** `kubectl get … -o json|yaml`
15
+ or `kubectl describe` on pods, deployments, statefulsets, daemonsets, jobs, configmaps or
16
+ secrets; `kubectl get secret`; `kubectl config view --raw`; `env`, `printenv`, `export -p`
17
+ (including `env | …`); `docker inspect`; `gh secret`, `gh api …/secrets`, `gh auth token`;
18
+ `vault read`, `vault kv get`; cloud secret-manager reads; `pulumi … --show-secrets`;
19
+ reading kubeconfigs, `.env*` files, private keys or token files.
20
+ - **[contract] Project the field you need and say which one:** `-o jsonpath='{.spec.containers[*].image}'`,
21
+ `-o custom-columns=…`, `kubectl get … -o name`, status fields, `kubectl logs`, `docker inspect --format`.
22
+ Read a `.env.example`, never the `.env`.
23
+
24
+ ## Values
25
+
26
+ - **[contract] Never paste, echo or log a credential**, and never put one in a brief, handoff,
27
+ issue, PR, message or commit. Reference a secret by name only (secret/key, environment variable).
28
+ - **[contract] When a value appears in output, stop.** Do not copy it onward. Report
29
+ **SECURITY INCIDENT** with the file or tool-call location and the credential kind, and ask for
30
+ rotation. Do not retry the command in another form.
31
+ - **[contract] Redact before archiving.** Scan and redact a work record before it is archived or
32
+ shared (see [`local-work-artifacts.md`](./local-work-artifacts.md)).
33
+ - **[contract] The escape hatch is a human's.** `CRATIS_HOOKS_ALLOW_SECRET_READS=1` is set by the
34
+ person who authorized the read, in the harness environment, never by an agent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cratis/pi",
3
- "version": "7.100.0",
3
+ "version": "7.101.0",
4
4
  "description": "Configuration-aware Cratis AI integration for Pi",
5
5
  "type": "module",
6
6
  "files": [