@cratis/pi 7.102.0 → 7.103.1

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 (152) hide show
  1. package/package/corpus/harnesses/copilot/instructions/profiles.instructions.md +10 -1
  2. package/package/corpus/harnesses/copilot/instructions/pull-requests.instructions.md +2 -0
  3. package/package/corpus/harnesses/copilot/instructions/secrets.instructions.md +4 -0
  4. package/package/corpus/hooks/README.md +11 -1
  5. package/package/corpus/hooks/scripts/cratis-check-pr.mjs +12 -1
  6. package/package/corpus/hooks/scripts/cratis-guard-secrets.sh +79 -23
  7. package/package/corpus/hooks/scripts/cratis-guard-writes.sh +145 -40
  8. package/package/corpus/rules/profiles.md +10 -1
  9. package/package/corpus/rules/pull-requests.md +2 -0
  10. package/package/corpus/rules/secrets.md +4 -0
  11. package/package/corpus/skills/analyzing-dotnet-performance/LICENSE +23 -0
  12. package/package/corpus/skills/analyzing-dotnet-performance/NOTICE +36 -0
  13. package/package/corpus/skills/analyzing-dotnet-performance/SKILL.md +214 -0
  14. package/package/corpus/skills/analyzing-dotnet-performance/references/async-patterns.md +122 -0
  15. package/package/corpus/skills/analyzing-dotnet-performance/references/collections-and-linq.md +220 -0
  16. package/package/corpus/skills/analyzing-dotnet-performance/references/critical-patterns.md +291 -0
  17. package/package/corpus/skills/analyzing-dotnet-performance/references/io-and-serialization.md +135 -0
  18. package/package/corpus/skills/analyzing-dotnet-performance/references/memory-and-strings.md +219 -0
  19. package/package/corpus/skills/analyzing-dotnet-performance/references/regex-patterns.md +95 -0
  20. package/package/corpus/skills/analyzing-dotnet-performance/references/structural-patterns.md +38 -0
  21. package/package/corpus/skills/binlog-generation/LICENSE +23 -0
  22. package/package/corpus/skills/binlog-generation/NOTICE +22 -0
  23. package/package/corpus/skills/binlog-generation/SKILL.md +129 -0
  24. package/package/corpus/skills/check-bin-obj-clash/LICENSE +23 -0
  25. package/package/corpus/skills/check-bin-obj-clash/NOTICE +27 -0
  26. package/package/corpus/skills/check-bin-obj-clash/SKILL.md +127 -0
  27. package/package/corpus/skills/check-bin-obj-clash/references/common-causes-and-fixes.md +206 -0
  28. package/package/corpus/skills/coverage-analysis/LICENSE +23 -0
  29. package/package/corpus/skills/coverage-analysis/NOTICE +32 -0
  30. package/package/corpus/skills/coverage-analysis/SKILL.md +198 -0
  31. package/package/corpus/skills/coverage-analysis/references/guidelines.md +68 -0
  32. package/package/corpus/skills/coverage-analysis/references/output-format.md +91 -0
  33. package/package/corpus/skills/coverage-analysis/references/report-generation.md +58 -0
  34. package/package/corpus/skills/coverage-analysis/references/setup-discovery.md +307 -0
  35. package/package/corpus/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 +80 -0
  36. package/package/corpus/skills/coverage-analysis/scripts/CoverageData.ps1 +272 -0
  37. package/package/corpus/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 +65 -0
  38. package/package/corpus/skills/cratis-screenplay-command-surface/SKILL.md +26 -1
  39. package/package/corpus/skills/cratis-screenplay-projections/SKILL.md +17 -0
  40. package/package/corpus/skills/cratis-screenplay-render-and-gap-fill/SKILL.md +17 -0
  41. package/package/corpus/skills/cratis-screenplay-specifications/SKILL.md +8 -0
  42. package/package/corpus/skills/cratis-screenplay-toolchain/SKILL.md +1 -1
  43. package/package/corpus/skills/cratis-screenplay-toolchain/references/cheat-sheet-example.md +1 -0
  44. package/package/corpus/skills/cratis-screenplay-toolchain/references/fast-path.md +7 -0
  45. package/package/corpus/skills/cratis-screenplay-toolchain/references/traps.md +1 -0
  46. package/package/corpus/skills/cratis-screenplay-ui-composition/SKILL.md +40 -0
  47. package/package/corpus/skills/cratis-screenplay-ui-composition/references/response-continuations.md +1 -0
  48. package/package/corpus/skills/cratis-screenplay-ui-composition/references/worked-flow.md +1 -0
  49. package/package/corpus/skills/cratis-stage-rendering-and-sandbox/references/admission.md +3 -2
  50. package/package/corpus/skills/csharp-refactoring/LICENSE +23 -0
  51. package/package/corpus/skills/csharp-refactoring/NOTICE +22 -0
  52. package/package/corpus/skills/csharp-refactoring/SKILL.md +175 -0
  53. package/package/corpus/skills/csharp-refactoring/references/operation-catalog.md +37 -0
  54. package/package/corpus/skills/directory-build-organization/LICENSE +23 -0
  55. package/package/corpus/skills/directory-build-organization/NOTICE +21 -0
  56. package/package/corpus/skills/directory-build-organization/SKILL.md +205 -0
  57. package/package/corpus/skills/directory-build-organization/references/common-patterns.md +56 -0
  58. package/package/corpus/skills/directory-build-organization/references/multi-level-examples.md +167 -0
  59. package/package/corpus/skills/directory-build-organization/references/targetframework-props-pitfall.md +54 -0
  60. package/package/corpus/skills/dotnet-aot-compat/LICENSE +23 -0
  61. package/package/corpus/skills/dotnet-aot-compat/NOTICE +18 -0
  62. package/package/corpus/skills/dotnet-aot-compat/SKILL.md +265 -0
  63. package/package/corpus/skills/dotnet-aot-compat/references/polyfills.md +43 -0
  64. package/package/corpus/skills/dotnet-trace-collect/LICENSE +23 -0
  65. package/package/corpus/skills/dotnet-trace-collect/NOTICE +65 -0
  66. package/package/corpus/skills/dotnet-trace-collect/SKILL.md +116 -0
  67. package/package/corpus/skills/dotnet-trace-collect/references/dotnet-monitor.md +97 -0
  68. package/package/corpus/skills/dotnet-trace-collect/references/dotnet-trace-collect-linux.md +84 -0
  69. package/package/corpus/skills/dotnet-trace-collect/references/dotnet-trace-collect.md +91 -0
  70. package/package/corpus/skills/dotnet-trace-collect/references/perfcollect.md +200 -0
  71. package/package/corpus/skills/dotnet-trace-collect/references/perfview.md +141 -0
  72. package/package/corpus/skills/dotnet-trace-collect/references/tool-selection.md +160 -0
  73. package/package/corpus/skills/dump-collect/LICENSE +23 -0
  74. package/package/corpus/skills/dump-collect/NOTICE +40 -0
  75. package/package/corpus/skills/dump-collect/SKILL.md +97 -0
  76. package/package/corpus/skills/dump-collect/references/container-dumps.md +412 -0
  77. package/package/corpus/skills/dump-collect/references/coreclr-dumps.md +108 -0
  78. package/package/corpus/skills/dump-collect/references/nativeaot-dumps.md +280 -0
  79. package/package/corpus/skills/eliminate-redundant-restores/LICENSE +23 -0
  80. package/package/corpus/skills/eliminate-redundant-restores/NOTICE +19 -0
  81. package/package/corpus/skills/eliminate-redundant-restores/SKILL.md +112 -0
  82. package/package/corpus/skills/extension-points/LICENSE +23 -0
  83. package/package/corpus/skills/extension-points/NOTICE +18 -0
  84. package/package/corpus/skills/extension-points/SKILL.md +248 -0
  85. package/package/corpus/skills/filter-syntax/LICENSE +23 -0
  86. package/package/corpus/skills/filter-syntax/NOTICE +17 -0
  87. package/package/corpus/skills/filter-syntax/SKILL.md +179 -0
  88. package/package/corpus/skills/including-generated-files/LICENSE +23 -0
  89. package/package/corpus/skills/including-generated-files/NOTICE +17 -0
  90. package/package/corpus/skills/including-generated-files/SKILL.md +139 -0
  91. package/package/corpus/skills/microbenchmarking/LICENSE +23 -0
  92. package/package/corpus/skills/microbenchmarking/NOTICE +36 -0
  93. package/package/corpus/skills/microbenchmarking/SKILL.md +131 -0
  94. package/package/corpus/skills/microbenchmarking/references/bdn-internals-and-tuning.md +143 -0
  95. package/package/corpus/skills/microbenchmarking/references/comparison-strategies.md +200 -0
  96. package/package/corpus/skills/microbenchmarking/references/diagnosers-and-exporters.md +150 -0
  97. package/package/corpus/skills/microbenchmarking/references/project-setup-and-running.md +189 -0
  98. package/package/corpus/skills/microbenchmarking/references/writing-benchmarks.md +310 -0
  99. package/package/corpus/skills/msbuild/LICENSE +23 -0
  100. package/package/corpus/skills/msbuild/NOTICE +55 -0
  101. package/package/corpus/skills/msbuild/SKILL.md +117 -0
  102. package/package/corpus/skills/msbuild/references/antipatterns/additional-antipatterns.md +291 -0
  103. package/package/corpus/skills/msbuild/references/antipatterns/incremental-build-inputs-outputs.md +57 -0
  104. package/package/corpus/skills/msbuild/references/antipatterns/private-assets.md +29 -0
  105. package/package/corpus/skills/msbuild/references/binlog-failure-analysis.md +85 -0
  106. package/package/corpus/skills/msbuild/references/binlog-generation.md +69 -0
  107. package/package/corpus/skills/msbuild/references/build-perf-baseline.md +493 -0
  108. package/package/corpus/skills/msbuild/references/build-perf-diagnostics.md +268 -0
  109. package/package/corpus/skills/msbuild/references/copy-to-output-directory.md +183 -0
  110. package/package/corpus/skills/msbuild/references/eval-performance.md +190 -0
  111. package/package/corpus/skills/msbuild/references/extension-points.md +281 -0
  112. package/package/corpus/skills/msbuild/references/incremental-build.md +365 -0
  113. package/package/corpus/skills/msbuild/references/msbuild-antipatterns.md +401 -0
  114. package/package/corpus/skills/orleans/LICENSE +21 -0
  115. package/package/corpus/skills/orleans/NOTICE +64 -0
  116. package/package/corpus/skills/orleans/SKILL.md +140 -0
  117. package/package/corpus/skills/orleans/references/anti-patterns.md +660 -0
  118. package/package/corpus/skills/orleans/references/configuration-api.md +933 -0
  119. package/package/corpus/skills/orleans/references/examples.md +66 -0
  120. package/package/corpus/skills/orleans/references/grain-api.md +784 -0
  121. package/package/corpus/skills/orleans/references/grains.md +75 -0
  122. package/package/corpus/skills/orleans/references/hosting.md +71 -0
  123. package/package/corpus/skills/orleans/references/implementation.md +591 -0
  124. package/package/corpus/skills/orleans/references/official-docs-index.md +250 -0
  125. package/package/corpus/skills/orleans/references/patterns.md +811 -0
  126. package/package/corpus/skills/orleans/references/persistence-api.md +495 -0
  127. package/package/corpus/skills/orleans/references/primitive-selection.md +35 -0
  128. package/package/corpus/skills/orleans/references/scheduling-and-services.md +295 -0
  129. package/package/corpus/skills/orleans/references/serialization-api.md +299 -0
  130. package/package/corpus/skills/orleans/references/streaming-api.md +396 -0
  131. package/package/corpus/skills/orleans/references/testing-patterns.md +210 -0
  132. package/package/corpus/skills/platform-detection/LICENSE +23 -0
  133. package/package/corpus/skills/platform-detection/NOTICE +17 -0
  134. package/package/corpus/skills/platform-detection/SKILL.md +230 -0
  135. package/package/corpus/skills/platform-detection/references/command-mode.md +27 -0
  136. package/package/corpus/skills/profiling/LICENSE +21 -0
  137. package/package/corpus/skills/profiling/NOTICE +50 -0
  138. package/package/corpus/skills/profiling/SKILL.md +144 -0
  139. package/package/corpus/skills/profiling/references/commands.md +439 -0
  140. package/package/corpus/skills/profiling/references/patterns.md +586 -0
  141. package/package/corpus/skills/profiling/references/profiling.md +190 -0
  142. package/package/corpus/skills/run-tests/LICENSE +23 -0
  143. package/package/corpus/skills/run-tests/NOTICE +19 -0
  144. package/package/corpus/skills/run-tests/SKILL.md +305 -0
  145. package/package/corpus/skills/target-authoring/LICENSE +23 -0
  146. package/package/corpus/skills/target-authoring/NOTICE +17 -0
  147. package/package/corpus/skills/target-authoring/SKILL.md +165 -0
  148. package/package/corpus/skills/vectorization/LICENSE +23 -0
  149. package/package/corpus/skills/vectorization/NOTICE +17 -0
  150. package/package/corpus/skills/vectorization/SKILL.md +189 -0
  151. package/package/profile-catalog.json +85 -1
  152. package/package.json +1 -1
@@ -87,11 +87,20 @@ profile includes neither Screenplay nor Stage.
87
87
  | Profile ID | Description | Automatically Includes |
88
88
  |---|---|---|
89
89
  | `cratis/engineering` | Engineering conventions and workflows | `cratis/engineering/core`, `cratis/engineering/csharp`, `cratis/engineering/elixir`, `cratis/engineering/go`, `cratis/engineering/kotlin`, `cratis/engineering/react`, `cratis/engineering/typescript` |
90
- | `cratis/engineering/csharp` | C# engineering conventions and framework Orleans jobs (`cratis-orleans-jobs`, alongside `orleans.md`) | `cratis/engineering/core` |
90
+ | `cratis/engineering/csharp` | C# engineering conventions, .NET engineering skills, and framework Orleans jobs (`cratis-orleans-jobs`, alongside `orleans.md`) | `cratis/engineering/core`, `cratis/engineering/dotnet` |
91
+ | `cratis/engineering/dotnet` | Upstream .NET engineering workflows | `cratis/engineering/dotnet/performance`, `cratis/engineering/dotnet/diagnostics`, `cratis/engineering/dotnet/msbuild`, `cratis/engineering/dotnet/orleans`, `cratis/engineering/dotnet/testing`, `cratis/engineering/dotnet/refactoring` |
92
+ | `cratis/engineering/dotnet/performance` | Microbenchmarking, performance patterns, SIMD, and profiling | (no child profiles) |
93
+ | `cratis/engineering/dotnet/diagnostics` | Trace and crash-dump collection | (no child profiles) |
94
+ | `cratis/engineering/dotnet/msbuild` | MSBuild troubleshooting, build authoring, output paths, and binlogs | (no child profiles) |
95
+ | `cratis/engineering/dotnet/orleans` | Orleans primitives, hosting, persistence, and runtime behavior | (no child profiles) |
96
+ | `cratis/engineering/dotnet/testing` | Test-platform detection, runner commands, filters, and coverage | (no child profiles) |
97
+ | `cratis/engineering/dotnet/refactoring` | Behavior-preserving C# refactoring and AOT compatibility | (no child profiles) |
91
98
  | `cratis/engineering/go` | Go framework parity and immutable module releases | `cratis/engineering/core`, `cratis/language/go` |
92
99
  | `cratis/engineering/typescript` | TypeScript engineering conventions | `cratis/engineering/core` |
93
100
  | `cratis/engineering/react` | React engineering conventions | `cratis/engineering/core` |
94
101
 
102
+ The `cratis/engineering/dotnet` profiles carry upstream skills under MIT, with attribution and changes in each skill's NOTICE. Cratis rules and the `cratis-*` skills win where they differ.
103
+
95
104
  The `cratis/engineering` profile also carries [issue-assignment.md](../../../rules/issue-assignment.md): when an agent starts work on a numbered issue in a known repository and nobody is assigned, it assigns the current `gh` user.
96
105
 
97
106
  ### Language Profiles
@@ -9,6 +9,8 @@ applyTo: "**/*"
9
9
 
10
10
  **The PR description is published verbatim as the GitHub release. In repositories released by `cratis/release-action`, it also closes every issue written as `(#n)`; elsewhere, follow the repository's own release and issue-closing process.** Write the note the person upgrading should read, not a development write-up. The same contract applies to release notes typed into a manual workflow run or written straight into a release. Where it is installed, the `policy / verify` check enforces it on release-bound PRs into the default branch carrying `major`/`minor`/`patch` (it re-runs when the label is added); its release-note step warns on `no-release` and unlabelled PRs, which must still be written this way because any of them may become releasable. `release-action` does not run for a `no-release` PR, so `(#n)` closes nothing there. Editing the description re-runs the check. HTML comments are not shown on the release page and the check ignores them for headings, keywords and placeholders, and `release-action` removes them before publishing, so a `(#n)` only inside a comment closes nothing; the check fails it, so never put an issue reference in a comment. The template's own comment can stay: its `(#123)` examples are inline code, and the comment is not published.
11
11
 
12
+ **A change to a hook script or its wiring (`.cratis/ai/hooks/**`) needs a release-note line that tells consumers to run `cratis ai update`; a hook fix does not reach a repository until they do.**
13
+
12
14
  ### Allowed shape
13
15
 
14
16
  - Follow the repository's pull request template (`.github/pull_request_template.md`) within this contract.
@@ -21,6 +21,10 @@ Every line is tagged **[contract]** (binding) per the Three Levels of Authority
21
21
  - **[contract] Project the field you need and say which one:** `-o jsonpath='{.spec.containers[*].image}'`,
22
22
  `-o custom-columns=…`, `kubectl get … -o name`, status fields, `kubectl logs`, `docker inspect --format`.
23
23
  Read a `.env.example`, never the `.env`.
24
+ - **[contract] The hook reads command text only.** It cannot see a command piped into a shell
25
+ (`… | sh`, `bash -c "$(…)"`), a recursive search with no named file (`grep -r PASSWORD .`), or a
26
+ value reached through a variable, alias or script. These stay your rule to keep; output redaction
27
+ is the only backstop.
24
28
 
25
29
  ## Values
26
30
 
@@ -11,7 +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
+ | 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; also behind `/usr/bin/env`, `command`, attached options such as `-ePATTERN`, a bare `{.}` projection) does not run unless it projects a field; piped shells and recursive searches with no named file stay uncovered; see [`secrets.md`](../rules/secrets.md) |
15
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 |
16
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 |
17
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 |
@@ -252,6 +252,16 @@ execution, so it cannot validate a body that the command has yet to write, even
252
252
  already exists. Dependabot's generated description remains exempt (including GitHub CLI's
253
253
  `app/dependabot` bot identity), but its release-intent label is still checked.
254
254
 
255
+ A release that changes a hook script or its wiring must say in its release notes that consumers run
256
+ `cratis ai update`: hooks are managed files, so a fix reaches a repository only once it updates.
257
+
258
+ The direct checker exits non-zero, with remediation text, in a `github.com/Cratis/` repository whose
259
+ workflows hold no policy caller (none of the recognized callers above): without one, nothing enforces the
260
+ pull-request contract there, and a pass would claim otherwise. A repository outside Cratis still falls back to
261
+ the reviewed pin below, and the Bash guard stays a no-op where there is no caller. A contract spec in the AI corpus's own
262
+ verification runs the checker over vendored copies of the callers Cratis/Workflows publishes; with
263
+ `CRATIS_CONTRACT_LIVE=1` it compares them with Cratis/Workflows `main`.
264
+
255
265
  Run the same checker directly:
256
266
 
257
267
  ```bash
@@ -71,6 +71,13 @@ function callers(cwd = process.cwd()) {
71
71
  } catch { return []; }
72
72
  }
73
73
 
74
+ function missingCaller(repository) {
75
+ return `No Cratis/Workflows policy caller found for ${repository}, so its pull-request contract is not enforced and this check cannot vouch for it. `
76
+ + 'Add .github/workflows/verify-pull-request-policy.yml calling Cratis/Workflows/.github/workflows/verify-pull-request-policy.yml '
77
+ + '(a legacy verify-release-notes, verify-semver-label or verify-release-intent caller also counts), then run this check again. '
78
+ + 'Cratis/Workflows bootstrap-common-workflows installs the caller; if the repository is meant to have none, do not use this check there.';
79
+ }
80
+
74
81
  function repositoryName(value) {
75
82
  return value?.replace(/^(?:https?:\/\/github\.com\/|(?:ssh:\/\/)?git@github\.com[:/])/i, '')
76
83
  .replace(/^github\.com\//i, '').replace(/\/$/, '').replace(/\.git$/i, '');
@@ -216,7 +223,11 @@ function check(argv, repositoryCallers, lookupOptions) {
216
223
  // The release-intent check above still applies to bot-authored pull requests.
217
224
  if (author === 'dependabot[bot]') return 0;
218
225
  const base = values.base || pull?.baseRefName || defaultBranch;
219
- const file = rules(warn, repositoryCallers || targetCallers(repository, process.cwd(), warn));
226
+ const found = repositoryCallers || targetCallers(repository, process.cwd(), warn);
227
+ // A Cratis repository without a policy caller has no enforced pull-request contract; passing here would
228
+ // let the author believe it has one. A fetch failure is not this case: targetCallers falls back to a caller.
229
+ if (!found.length && /^Cratis\/[^/]+$/i.test(repository)) throw new Error(missingCaller(repository));
230
+ const file = rules(warn, found);
220
231
  if (!file) return 3;
221
232
  const env = { PATH: process.env.PATH, HOME: homedir(), LANG: 'C.UTF-8',
222
233
  PR_BODY: body, PR_LABELS: JSON.stringify([...labels]),
@@ -10,8 +10,9 @@
10
10
  # Exit 2 blocks the tool call and sends stderr back to the model; every other command exits 0.
11
11
  #
12
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.
13
+ # file or a pipe into a shell is invisible to it, as is a recursive search with no named file; a projection it
14
+ # cannot see through (a template or `-o jsonpath` that is not a bare `{.}`) is trusted. A redirect does not
15
+ # exempt anything: `env > f` is blocked like `env`. See .cratis/ai/hooks/README.md.
15
16
  #
16
17
  # Escape hatch for a human who authorized the read — set in the environment the agent harness was started
17
18
  # from, never inside the command (an assignment in the command is ignored):
@@ -54,13 +55,26 @@ function base(word) { word = tolower(word); sub(/.*\//, "", word); return word }
54
55
 
55
56
  # ── tokenizer: splits text into simple commands, queueing nested command text ──
56
57
  function flush_word() {
57
- if (inword) W[++nw] = word
58
- word = ""; inword = 0
58
+ if (inword) { W[++nw] = word; WP[nw] = wplain }
59
+ word = ""; inword = 0; wquoted = 0; wplain = 0
59
60
  }
60
- function end_cmd() {
61
+ # Output redirections are removed before a command is analyzed, so a redirect never shifts a CLI's subcommand
62
+ # or operands (`kubectl get > /dev/stdout secrets`). Nothing is exempted: the command itself is still analyzed.
63
+ # A word is a redirection only when its whole operator lies in the word's unquoted prefix (WP: characters before
64
+ # the first quoted or escaped one), so `1">"b` and `">x"` stay operands; input redirections (`< file`) stay too.
65
+ function end_cmd( k, m, w) {
61
66
  flush_word()
62
- if (nw > 0) analyze(W, 1, nw)
63
- split("", W); nw = 0
67
+ m = 0
68
+ for (k = 1; k <= nw; k++) {
69
+ w = W[k]
70
+ if (match(w, /^([0-9]*|&)>[>|]?/) && RLENGTH <= WP[k]) {
71
+ if (RLENGTH == length(w)) k++
72
+ continue
73
+ }
74
+ V[++m] = w
75
+ }
76
+ if (m > 0) analyze(V, 1, m)
77
+ split("", W); split("", WP); split("", V); nw = 0
64
78
  }
65
79
  function cmdsub(s, i, n, start, depth, c, j) {
66
80
  start = i; depth = 1
@@ -97,23 +111,23 @@ function read_heredocs(s, i, n, h, e, line, cmp, body) {
97
111
  return i
98
112
  }
99
113
  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)
114
+ n = length(s); i = 1; nw = 0; word = ""; inword = 0; wquoted = 0; wplain = 0; nheredoc = 0; hd_shell = 0
115
+ split("", W); split("", WP)
102
116
  while (i <= n) {
103
117
  c = substr(s, i, 1)
104
118
  if (c == "\\") {
105
119
  c2 = substr(s, i + 1, 1)
106
120
  if (c2 == "\n") { i += 2; continue }
107
- word = word c2; inword = 1; i += 2; continue
121
+ wquoted = 1; word = word c2; inword = 1; i += 2; continue
108
122
  }
109
123
  if (c == "\047") {
110
- inword = 1
124
+ wquoted = 1; inword = 1
111
125
  j = index(substr(s, i + 1), "\047")
112
126
  if (j == 0) { word = word substr(s, i + 1); i = n + 1; continue }
113
127
  word = word substr(s, i + 1, j - 1); i += j + 1; continue
114
128
  }
115
129
  if (c == "\"") {
116
- inword = 1; i++
130
+ wquoted = 1; inword = 1; i++
117
131
  while (i <= n) {
118
132
  c = substr(s, i, 1)
119
133
  if (c == "\\") { word = word substr(s, i + 1, 1); i += 2; continue }
@@ -128,9 +142,9 @@ function tokenize(s, i, n, c, c2, j, dash, delim) {
128
142
  }
129
143
  continue
130
144
  }
131
- if (c == "$" && substr(s, i + 1, 1) == "(") { inword = 1; i = cmdsub(s, i + 2, n); word = word "x"; continue }
145
+ if (c == "$" && substr(s, i + 1, 1) == "(") { wquoted = 1; inword = 1; i = cmdsub(s, i + 2, n); word = word "x"; continue }
132
146
  if (c == "`") {
133
- inword = 1
147
+ wquoted = 1; inword = 1
134
148
  j = index(substr(s, i + 1), "`")
135
149
  if (j == 0) { i = n + 1; continue }
136
150
  enqueue(substr(s, i + 1, j - 1)); i += j + 1; word = word "x"; continue
@@ -146,6 +160,9 @@ function tokenize(s, i, n, c, c2, j, dash, delim) {
146
160
  if (nheredoc > 0) i = read_heredocs(s, i, n)
147
161
  continue
148
162
  }
163
+ # `>&`, `>|` and `&>` are parts of a redirection operator, not command separators.
164
+ if ((c == "&" || c == "|") && inword && !wquoted && word ~ />$/) { word = word c; wplain++; i++; continue }
165
+ if (c == "&" && substr(s, i + 1, 1) == ">") { flush_word(); word = c; inword = 1; wplain = 1; i++; continue }
149
166
  if (c == ";" || c == "&" || c == "|" || c == "(" || c == ")") { end_cmd(); i++; continue }
150
167
  if (substr(s, i, 2) == "<<" && substr(s, i, 3) != "<<<") {
151
168
  flush_word(); i += 2; dash = 0
@@ -161,6 +178,7 @@ function tokenize(s, i, n, c, c2, j, dash, delim) {
161
178
  nheredoc++; HD[nheredoc] = delim; HDDASH[nheredoc] = dash
162
179
  continue
163
180
  }
181
+ if (!wquoted) wplain++
164
182
  word = word c; inword = 1; i++
165
183
  }
166
184
  end_cmd()
@@ -169,7 +187,7 @@ function tokenize(s, i, n, c, c2, j, dash, delim) {
169
187
  # ── option parsing shared by the CLIs: positionals P/PI, output format, -f/--format value, --raw ──
170
188
  function kparse(A, s, n, shortvals, i, w, eq, key, val, f, rest) {
171
189
  split("", P); split("", PI)
172
- np = 0; outfmt = ""; fmtval = ""; dd = 0; raw = 0
190
+ np = 0; outfmt = ""; fmtval = ""; tmpl = ""; dd = 0; raw = 0
173
191
  for (i = s; i <= n; i++) {
174
192
  w = A[i]
175
193
  if (w == "--") { dd = i; break }
@@ -178,6 +196,7 @@ function kparse(A, s, n, shortvals, i, w, eq, key, val, f, rest) {
178
196
  if (eq) { key = substr(w, 1, eq - 1); val = substr(w, eq + 1) }
179
197
  if (key == "--output") { if (!eq) { i++; val = A[i] } outfmt = tolower(val) }
180
198
  else if (key == "--format") { if (!eq) { i++; val = A[i] } fmtval = val }
199
+ else if (key == "--template") { if (!eq) { i++; val = A[i] } tmpl = val }
181
200
  else if (key == "--raw") raw = 1
182
201
  else if (!eq && (key in LV)) i++
183
202
  continue
@@ -196,6 +215,11 @@ function kparse(A, s, n, shortvals, i, w, eq, key, val, f, rest) {
196
215
  np++; P[np] = w; PI[np] = i
197
216
  }
198
217
  }
218
+ # `-o jsonpath={.}` and `-o go-template={{.}}` print the whole object, exactly like -o json.
219
+ function proj_leaks(t) {
220
+ t = tolower(t); gsub(/[ \t]/, "", t)
221
+ return t ~ /[{](\.|@|\.items|\.items\[\*\])[}]/
222
+ }
199
223
  function skipopts(A, s, n, vals) {
200
224
  while (s <= n && substr(A[s], 1, 1) == "-") {
201
225
  if (length(A[s]) == 2 && index(vals, substr(A[s], 2, 1))) s += 2
@@ -257,6 +281,8 @@ function kube(A, s, n, verb, m, parts, k, r, rlist) {
257
281
  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
282
  else if (outfmt == "json" || outfmt == "yaml")
259
283
  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")
284
+ else if ((outfmt ~ /^(jsonpath|go-template|jsonpath-as-json)=/ || tmpl != "") && proj_leaks(tmpl outfmt))
285
+ report("kubectl get " r " with a projection that selects the whole object 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
286
  }
261
287
  }
262
288
  }
@@ -323,19 +349,36 @@ function gcloud(A, s, n, k) {
323
349
  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
350
  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
351
  }
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 " }
352
+ # Scans one attached or clustered short option (-ePATTERN, -nePATTERN, -m5, -F:) of a pattern or script tool:
353
+ # sc_pat is set when it supplies the pattern or script, sc_skip when its value is the next word.
354
+ function scan_short(w, shortvals, sed_like, i, c, last) {
355
+ sc_pat = 0; sc_skip = 0; last = length(w)
356
+ for (i = 2; i <= last; i++) {
357
+ c = substr(w, i, 1)
358
+ if (c == "e" || c == "f") { sc_pat = 1; sc_skip = (i == last); return }
359
+ if (index(shortvals, c)) { sc_skip = (i == last); return }
360
+ if (sed_like && c == "i") return
361
+ }
362
+ }
363
+ function reader(A, s, n, b, k, w, mode, havepat, need, afterdd, kind, valued, valued2, shortvals) {
364
+ b = base(A[s]); mode = ""; valued = " "; valued2 = " "; shortvals = ""
365
+ if (b ~ /^(grep|egrep|fgrep|rg|ag)$/) { mode = "pattern"; shortvals = (b ~ /^(grep|egrep|fgrep)$/) ? "mABCdD" : (b == "rg") ? "mABCdjEMrgtT" : "ABCGgmpW"; valued = " --glob --type --type-not --max-count --include --exclude --exclude-dir --context --after-context --before-context --max-depth --threads " }
366
+ else if (b ~ /^(sed|awk|gawk|jq|yq)$/) { mode = "script"; shortvals = "vF"; valued = " --indent "; valued2 = " --arg --argjson --slurpfile --rawfile " }
330
367
  # The first operand is the pattern or script unless -e/-f/--regexp/--file/--expression supplies it; only the rest are files.
331
368
  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
369
+ if (mode != "") for (k = s + 1; k <= n; k++) {
370
+ w = A[k]
371
+ if (w == "--") break
372
+ if (w ~ /^--(regexp|file|expression|from-file)(=|$)/) havepat = 1
373
+ else if (w ~ /^-[a-zA-Z]/) { scan_short(w, shortvals, b == "sed"); if (sc_pat) havepat = 1 }
374
+ }
333
375
  need = (mode != "" && !havepat); afterdd = 0
334
376
  for (k = s + 1; k <= n; k++) {
335
377
  w = A[k]
336
378
  if (w == "--" && !afterdd) { afterdd = 1; continue }
337
379
  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++
380
+ if (mode != "" && w ~ /^-[a-zA-Z]/) { scan_short(w, shortvals, b == "sed"); if (sc_skip) k++ }
381
+ else if (mode != "" && (w ~ /^--(regexp|file|expression|from-file)$/ || index(valued, " " w " "))) k++
339
382
  else if (index(valued2, " " w " ")) k += 2
340
383
  continue
341
384
  }
@@ -345,13 +388,22 @@ function reader(A, s, n, b, k, w, mode, havepat, need, afterdd, kind, valued,
345
388
  }
346
389
  }
347
390
 
391
+ function drop_redirects(A, s, n, k, m, w) {
392
+ m = s
393
+ for (k = s + 1; k <= n; k++) {
394
+ w = A[k]
395
+ if (w ~ /^[0-9]*[<>]/) { if (w ~ /^[0-9]*[<>]+&?-?$/) k++; continue }
396
+ A[++m] = w
397
+ }
398
+ return m
399
+ }
348
400
  function analyze(A, s, n, w, b, k, names, p) {
349
401
  while (s <= n) {
350
402
  w = A[s]; b = base(w)
351
403
  if (is_assign(w)) { s++; continue }
352
404
  if (w ~ /^[0-9]*[<>]/) { s += (w ~ /^[0-9]*[<>]+&?-?$/) ? 2 : 1; continue }
353
405
  if (b in KW) { s++; continue }
354
- if (b == "command" || b == "builtin") { s++; if (s <= n && (A[s] == "-v" || A[s] == "-V")) return; continue }
406
+ if (b == "command" || b == "builtin") { s++; if (s <= n && (A[s] == "-v" || A[s] == "-V")) return; if (s <= n && A[s] == "-p") s++; continue }
355
407
  if (b == "sudo" || b == "doas") { s = skipopts(A, s + 1, n, "ugChprtUD"); continue }
356
408
  if (b == "xargs") { s = skipopts(A, s + 1, n, "IniPLdsEa"); continue }
357
409
  if (b == "timeout") { s = skipopts(A, s + 1, n, "ks") + 1; continue }
@@ -363,6 +415,7 @@ function analyze(A, s, n, w, b, k, names, p) {
363
415
  while (s <= n) {
364
416
  w = A[s]
365
417
  if (is_assign(w)) s++
418
+ else if (w ~ /^[0-9]*[<>]/) s += (w ~ /^[0-9]*[<>]+&?-?$/) ? 2 : 1
366
419
  else if (w == "-u" || w == "-C" || w == "-S" || w == "--unset" || w == "--chdir" || w == "--split-string") s += 2
367
420
  else if (substr(w, 1, 1) == "-") s++
368
421
  else break
@@ -393,6 +446,8 @@ function analyze(A, s, n, w, b, k, names, p) {
393
446
  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
447
  return
395
448
  }
449
+ # A redirect is not a variable name: `printenv > f` and `export > f` still dump the whole environment.
450
+ if (b == "printenv" || b == "export" || b == "declare" || b == "typeset") n = drop_redirects(A, s, n)
396
451
  if (b == "printenv") {
397
452
  names = 0
398
453
  for (k = s + 1; k <= n; k++) {
@@ -426,7 +481,8 @@ BEGIN {
426
481
  for (i in L) KW[L[i]] = 1
427
482
  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
483
  for (i in L) SENS[L[i]] = 1
429
- enqueue(ENVIRON["GS_COMMAND"])
484
+ CMD = ENVIRON["GS_COMMAND"]
485
+ enqueue(CMD)
430
486
  while (qi < queued) { qi++; tokenize(queue[qi]) }
431
487
  }
432
488
  AWK
@@ -18,23 +18,74 @@ set -euo pipefail
18
18
 
19
19
  # SCRIPTDIR, not a path relative to the caller: shellcheck resolves a plain relative `source=`
20
20
  # against the current working directory, and these hooks are linted from wherever CI happens to run.
21
+ script_directory="${BASH_SOURCE[0]%/*}"
22
+ [ "$script_directory" != "${BASH_SOURCE[0]}" ] || script_directory=.
21
23
  # shellcheck source=SCRIPTDIR/hook-lib.sh
22
- . "$(dirname "${BASH_SOURCE[0]}")/hook-lib.sh"
24
+ . "$script_directory/hook-lib.sh"
23
25
 
24
26
  [ "${CRATIS_HOOKS_ALLOW_PROTECTED_WRITES:-0}" = "1" ] && exit 0
25
27
 
26
- input="$(hook_read_stdin)"
28
+ # This guard runs before every write, so it avoids subprocesses where a builtin does the same job:
29
+ # the helpers below return through REPLY instead of printing into a command substitution.
30
+ # Read stdin with the read builtin: opening /dev/stdin fails with ENXIO when stdin is a socket (Linux, Node pipes).
31
+ input=""
32
+ IFS= read -r -d '' input || true
27
33
  [ -n "$input" ] || exit 0
28
34
  hook_have jq || exit 0
29
35
 
30
- root="$(hook_repo_root)"
31
- cwd="$(hook_json "$input" '.cwd')"
32
- [ -n "$cwd" ] || cwd="$root"
36
+ # One jq call reads every field the guard uses. Fields are NUL-terminated (embedded NULs are dropped,
37
+ # as a command substitution would drop them); a missing, null, false or unreadable path is empty.
38
+ cwd="" file="" notebook="" new_content="" new_string=""
39
+ {
40
+ IFS= read -r -d '' cwd
41
+ IFS= read -r -d '' file
42
+ IFS= read -r -d '' notebook
43
+ IFS= read -r -d '' new_content
44
+ IFS= read -r -d '' new_string
45
+ } < <(jq -j '
46
+ def field(path): ([try (path // empty) catch empty] | first // "")
47
+ | if type == "string" then . else tojson end
48
+ | gsub("\u0000"; "");
49
+ [field(.cwd), field(.tool_input.file_path), field(.tool_input.notebook_path),
50
+ field(.tool_input.content), field(.tool_input.new_string)]
51
+ | .[] | ., "\u0000"
52
+ ' <<<"$input" 2>/dev/null) || true
53
+ # Command substitution stripped trailing newlines from each field; keep that behaviour.
54
+ for field_name in cwd file notebook new_content new_string; do
55
+ while [[ "${!field_name}" == *$'\n' ]]; do printf -v "$field_name" '%s' "${!field_name%$'\n'}"; done
56
+ done
33
57
 
34
- file="$(hook_json "$input" '.tool_input.file_path')"
35
- [ -n "$file" ] || file="$(hook_json "$input" '.tool_input.notebook_path')"
58
+ [ -n "$cwd" ] || cwd="$(hook_repo_root)"
59
+ [ -n "$file" ] || file="$notebook"
36
60
  [ -n "$file" ] || exit 0
37
61
 
62
+ # dir_of / base_of / relative_to mirror dirname, basename and hook_relpath for the absolute,
63
+ # normalized paths used here. abspath mirrors hook_abspath. All return through REPLY.
64
+ dir_of() {
65
+ REPLY="${1%/*}"
66
+ [ -n "$REPLY" ] || REPLY=/
67
+ }
68
+
69
+ base_of() {
70
+ REPLY="${1##*/}"
71
+ [ -n "$REPLY" ] || REPLY=/
72
+ }
73
+
74
+ abspath() {
75
+ case "$1" in
76
+ /*) REPLY="$1" ;;
77
+ *) REPLY="${2%/}/$1" ;;
78
+ esac
79
+ }
80
+
81
+ relative_to() {
82
+ local root="${2%/}"
83
+ case "$1" in
84
+ "$root"/*) REPLY="${1#"$root"/}" ;;
85
+ *) REPLY="$1" ;;
86
+ esac
87
+ }
88
+
38
89
  # Lexically normalize before any Git lookup or parent walk, including nonexistent segments.
39
90
  # Component parsing preserves spaces and glob characters without depending on realpath.
40
91
  normalize_target_path() {
@@ -52,17 +103,22 @@ normalize_target_path() {
52
103
  *) normalized="$normalized/$component" ;;
53
104
  esac
54
105
  done
55
- printf '%s\n' "${normalized:-/}"
106
+ REPLY="${normalized:-/}"
56
107
  }
57
108
 
58
- file="$(hook_abspath "$file" "$cwd")"
59
- file="$(normalize_target_path "$file")"
60
- # Pi can run the bundled script in a consumer checkout; this root is only for diagnostics.
61
- root="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || printf '%s\n' "$cwd")"
62
- rel="$(hook_relpath "$file" "$root")"
63
- base="$(basename "$file")"
109
+ abspath "$file" "$cwd"
110
+ normalize_target_path "$REPLY"
111
+ file="$REPLY"
112
+ base_of "$file"
113
+ base="$REPLY"
64
114
 
65
115
  block() {
116
+ # Pi can run the bundled script in a consumer checkout; this root is only for diagnostics,
117
+ # so it is looked up only when a write is actually refused.
118
+ local root rel
119
+ root="$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null || printf '%s\n' "$cwd")"
120
+ relative_to "$file" "$root"
121
+ rel="$REPLY"
66
122
  printf 'BLOCKED by cratis-guard-writes: %s\n\n%s\n\n%s\n' "$rel" "$1" "$2" >&2
67
123
  exit 2
68
124
  }
@@ -70,36 +126,70 @@ block() {
70
126
  # Check both the addressed path and its physical destination: settings may be symlinks to
71
127
  # templates, and an innocently named link may target a protected file. Preserve missing suffixes.
72
128
  physical_target_path() {
73
- local target="$1" directory suffix="" link hops=0
129
+ local target="$1" directory suffix="" link hops=0 start="$PWD"
130
+ physical_file=""
74
131
  while [ -L "$target" ]; do
75
132
  hops=$((hops + 1))
76
133
  [ "$hops" -le 40 ] || return 1
77
134
  link="$(readlink "$target")" || return 1
78
- target="$(hook_abspath "$link" "$(dirname "$target")")"
135
+ dir_of "$target"
136
+ abspath "$link" "$REPLY"
137
+ target="$REPLY"
79
138
  done
80
- directory="$(dirname "$target")"
81
- suffix="/$(basename "$target")"
139
+ dir_of "$target"
140
+ directory="$REPLY"
141
+ base_of "$target"
142
+ suffix="/$REPLY"
82
143
  while [ ! -d "$directory" ]; do
83
144
  [ "$directory" != "/" ] || return 1
84
- suffix="/$(basename "$directory")$suffix"
85
- directory="$(dirname "$directory")"
145
+ base_of "$directory"
146
+ suffix="/$REPLY$suffix"
147
+ dir_of "$directory"
148
+ directory="$REPLY"
86
149
  done
87
- directory="$(cd -P "$directory" 2>/dev/null && pwd -P)" || return 1
88
- printf '%s\n' "${directory%/}$suffix"
150
+ cd -P "$directory" 2>/dev/null || return 1
151
+ directory="$PWD"
152
+ cd "$start" 2>/dev/null || true
153
+ physical_file="${directory%/}$suffix"
154
+ }
155
+
156
+ # The origin URL, looked up once per repository per run.
157
+ origin_cache_root=""
158
+ origin_cache_value=""
159
+ repository_origin() {
160
+ if [ "$origin_cache_root" != "$1" ]; then
161
+ origin_cache_value="$(git -C "$1" remote get-url origin 2>/dev/null || true)"
162
+ origin_cache_root="$1"
163
+ fi
164
+ REPLY="$origin_cache_value"
165
+ }
166
+
167
+ # Case-insensitive ASCII match of the origin URL, equivalent to grep -Eiq on a single line.
168
+ origin_matches() {
169
+ # C locale, as the grep it replaced ran: a non-ASCII letter (the Kelvin sign) must not match [a-z].
170
+ local LC_ALL=C pattern="$1" matched=1
171
+ shopt -s nocasematch
172
+ # shellcheck disable=SC2034
173
+ [[ "$REPLY" =~ $pattern ]] && matched=0
174
+ shopt -u nocasematch
175
+ return "$matched"
89
176
  }
90
177
 
91
178
  # ── 4. Agent controls and installed managed trees ─────────────────────────────
92
179
  agent_control_target() {
93
- local target="$1" directory policy_root target_rel origin
94
- directory="$(dirname "$target")"
180
+ local target="$1" directory policy_root target_rel
181
+ dir_of "$target"
182
+ directory="$REPLY"
95
183
  # Keep the addressed spelling here, even when an adapter directory is a symlink.
96
184
  # .git is a directory in a checkout and a file in a linked worktree.
97
185
  while [ ! -e "$directory/.git" ]; do
98
186
  [ "$directory" != "/" ] || return 1
99
- directory="$(dirname "$directory")"
187
+ dir_of "$directory"
188
+ directory="$REPLY"
100
189
  done
101
190
  policy_root="$directory"
102
- target_rel="$(hook_relpath "$target" "$policy_root")"
191
+ relative_to "$target" "$policy_root"
192
+ target_rel="$REPLY"
103
193
  if [ -f "$policy_root/.cratis/ai.manifest.json" ]; then
104
194
  case "$target_rel" in
105
195
  .cratis/ai/*) return 0 ;;
@@ -122,8 +212,8 @@ agent_control_target() {
122
212
  esac
123
213
  else
124
214
  # The corpus is authored source in Cratis/AI, not a managed installation.
125
- origin="$(git -C "$policy_root" remote get-url origin 2>/dev/null || true)"
126
- if printf '%s\n' "$origin" | LC_ALL=C grep -Eiq '^(https://github\.com/|git@github\.com:|ssh://git@github\.com(:[0-9]+)?/)cratis/ai(\.git)?/?$'; then
215
+ repository_origin "$policy_root"
216
+ if origin_matches '^(https://github\.com/|git@github\.com:|ssh://git@github\.com(:[0-9]+)?/)cratis/ai(\.git)?/?$'; then
127
217
  return 1
128
218
  fi
129
219
  fi
@@ -134,7 +224,7 @@ agent_control_target() {
134
224
  return 1
135
225
  }
136
226
 
137
- physical_file="$(physical_target_path "$file" || true)"
227
+ physical_target_path "$file" || physical_file=""
138
228
  if agent_control_target "$file" || { [ -n "$physical_file" ] && agent_control_target "$physical_file"; }; then
139
229
  block \
140
230
  "This target controls the agent's permissions or extensions, or belongs to an installed managed corpus/adapter tree. Agent Write/Edit calls may not alter their own controls or managed instructions." \
@@ -146,19 +236,30 @@ fi
146
236
  # Merely *mentioning* the string — documentation, a rule file, this corpus — is not a match.
147
237
  marker='^[[:space:]]*(//|/\*|#|<!--)[[:space:]]*@generated by Cratis'
148
238
 
239
+ # True when one of the first five lines of stdin carries the marker (head -n 5 | grep -E, without forks).
240
+ first_lines_carry_marker() {
241
+ local LC_ALL=C line count=0
242
+ while [ "$count" -lt 5 ] && { IFS= read -r line || [ -n "$line" ]; }; do
243
+ count=$((count + 1))
244
+ # shellcheck disable=SC2034
245
+ [[ "$line" =~ $marker ]] && return 0
246
+ done
247
+ return 1
248
+ }
249
+
250
+ # The cheap in-shell header test runs first; grep -I then confirms the file is text, as before.
149
251
  if [ -f "$file" ] \
150
- && LC_ALL=C grep -Iq . "$file" 2>/dev/null \
151
- && head -n 5 "$file" 2>/dev/null | LC_ALL=C grep -Eq "$marker"; then
252
+ && first_lines_carry_marker 2>/dev/null <"$file" \
253
+ && LC_ALL=C grep -Iq . "$file" 2>/dev/null; then
152
254
  block \
153
255
  "This file is generated output (its header marks it as generated by Cratis). Rule 15 [contract] in .cratis/ai/rules/general.md: never edit a generated file — the next build overwrites it." \
154
256
  "Instead: change the C# source it is generated from (the [Command] / [ReadModel] record) and rebuild with 'dotnet build -c Debug' to regenerate the proxy."
155
257
  fi
156
258
 
157
259
  # The same guard for authoring a *new* file that pretends to be generated output.
158
- new_content="$(hook_json "$input" '.tool_input.content')"
159
- [ -n "$new_content" ] || new_content="$(hook_json "$input" '.tool_input.new_string')"
260
+ [ -n "$new_content" ] || new_content="$new_string"
160
261
  if [ -n "$new_content" ] \
161
- && printf '%s\n' "$new_content" | head -n 5 | LC_ALL=C grep -Eq "$marker"; then
262
+ && first_lines_carry_marker <<<"$new_content"; then
162
263
  block \
163
264
  "The content being written opens with a Cratis generated-file header. Proxies are produced by the Cratis proxy generator, never hand-written." \
164
265
  "Instead: write the C# source and run 'dotnet build -c Debug' to generate the proxy."
@@ -166,16 +267,20 @@ fi
166
267
 
167
268
  # ── 2. Dependency manifests, lockfiles, SDK and NuGet configuration ───────────
168
269
  manifest_writes_allowed() {
169
- local setting="" origin directory policy_root
270
+ local setting="" directory policy_root start="$PWD"
170
271
  # Authorization belongs to the write target, which may be outside the session's checkout.
171
272
  # New files can have nonexistent parents; find their nearest existing directory first.
172
- directory="$(dirname "$file")"
273
+ dir_of "$file"
274
+ directory="$REPLY"
173
275
  while [ ! -d "$directory" ]; do
174
276
  [ "$directory" != "/" ] || return 1
175
- directory="$(dirname "$directory")"
277
+ dir_of "$directory"
278
+ directory="$REPLY"
176
279
  done
177
280
  # Follow existing directory symlinks before asking Git which repository owns the target.
178
- directory="$(cd -P "$directory" 2>/dev/null && pwd -P)" || return 1
281
+ cd -P "$directory" 2>/dev/null || return 1
282
+ directory="$PWD"
283
+ cd "$start" 2>/dev/null || true
179
284
  policy_root="$(git -C "$directory" rev-parse --show-toplevel 2>/dev/null)" || return 1
180
285
  if [ -f "$policy_root/.cratis/ai.json" ]; then
181
286
  # Only actual booleans override the default; jq's `// empty` would discard false.
@@ -185,9 +290,9 @@ manifest_writes_allowed() {
185
290
  true) return 0 ;;
186
291
  false) return 1 ;;
187
292
  esac
188
- origin="$(git -C "$policy_root" remote get-url origin 2>/dev/null || true)"
293
+ repository_origin "$policy_root"
189
294
  # Anchor host, owner and the single repository segment; never infer ownership from profiles.
190
- printf '%s\n' "$origin" | LC_ALL=C grep -Eiq '^(https://github\.com/|git@github\.com:|ssh://git@github\.com(:[0-9]+)?/)cratis/[a-z0-9_.-]+/?$'
295
+ origin_matches '^(https://github\.com/|git@github\.com:|ssh://git@github\.com(:[0-9]+)?/)cratis/[a-z0-9_.-]+/?$'
191
296
  }
192
297
 
193
298
  case "$base" in