mindforge-cc 11.9.2 → 11.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agent/CLAUDE.md +37 -13
- package/.agent/hooks/mindforge-block-no-verify.js +61 -13
- package/.agent/hooks/mindforge-config-protection.js +82 -3
- package/.agent/hooks/mindforge-context-monitor.js +1 -1
- package/.agent/hooks/mindforge-workflow-guard.js +2 -2
- package/.agent/hooks/run-with-flags.js +190 -20
- package/.agent/mindforge/browse.md +2 -2
- package/.agent/mindforge/checkpoint.md +1 -1
- package/.agent/mindforge/harness-audit.md +1 -1
- package/.agent/mindforge/orch-add-feature.md +1 -1
- package/.agent/mindforge/orch-build-mvp.md +1 -1
- package/.agent/mindforge/orch-change-feature.md +1 -1
- package/.agent/mindforge/orch-fix-defect.md +1 -1
- package/.agent/mindforge/orch-refine-code.md +1 -1
- package/.agent/mindforge/qa.md +2 -2
- package/.claude/CLAUDE.md +37 -13
- package/.claude/commands/mindforge/browse.md +2 -2
- package/.claude/commands/mindforge/checkpoint.md +1 -1
- package/.claude/commands/mindforge/harness-audit.md +1 -1
- package/.claude/commands/mindforge/orch-add-feature.md +1 -1
- package/.claude/commands/mindforge/orch-build-mvp.md +1 -1
- package/.claude/commands/mindforge/orch-change-feature.md +1 -1
- package/.claude/commands/mindforge/orch-fix-defect.md +1 -1
- package/.claude/commands/mindforge/orch-refine-code.md +1 -1
- package/.claude/commands/mindforge/qa.md +2 -2
- package/.mindforge/MINDFORGE-SCHEMA.json +1 -1
- package/.mindforge/config.json +3 -3
- package/.mindforge/engine/autonomous/headless-adapter.md +2 -2
- package/.mindforge/engine/temporal-protocol.md +2 -2
- package/.mindforge/governance/change-classifier.md +20 -4
- package/.mindforge/memory/sync-manifest.json +1 -1
- package/.mindforge/skills/agent-architecture-audit/SKILL.md +2 -2
- package/.mindforge/skills/orch-pipeline/SKILL.md +4 -4
- package/CHANGELOG.md +194 -0
- package/MINDFORGE.md +13 -6
- package/README.md +4 -3
- package/RELEASENOTES.md +2 -2
- package/SECURITY.md +22 -3
- package/bin/autonomous/auto-runner.js +65 -2
- package/bin/change-classifier.js +151 -16
- package/bin/dashboard/api-router.js +18 -38
- package/bin/dashboard/frontend/app.js +429 -0
- package/bin/dashboard/frontend/index.html +13 -406
- package/bin/dashboard/metrics-aggregator.js +46 -22
- package/bin/dashboard/server.js +160 -1
- package/bin/dashboard/sse-bridge.js +11 -8
- package/bin/engine/sre-manager.js +1 -1
- package/bin/engine/temporal-cli.js +56 -6
- package/bin/engine/verification-runner.js +134 -17
- package/bin/engine/verify-cli.js +25 -7
- package/bin/governance/approval-record.js +147 -0
- package/bin/governance/approve.js +12 -7
- package/bin/governance/policy-engine.js +33 -3
- package/bin/governance/policy-gate-hardened.js +36 -1
- package/bin/governance/verify-approvals.js +163 -0
- package/bin/harness-audit.js +224 -10
- package/bin/hooks/instinct-capture-hook.js +12 -4
- package/bin/install.js +63 -3
- package/bin/installer/harness-adapter-compliance.js +339 -28
- package/bin/installer/hook-registration.js +504 -0
- package/bin/installer-core.js +451 -63
- package/bin/learning/instinct-cli.js +7 -0
- package/bin/memory/vector-hub.js +196 -13
- package/bin/migrations/0.6.0-to-1.0.0.js +30 -25
- package/bin/migrations/1.0.0-to-2.0.0.js +22 -23
- package/bin/mindforge-cli.js +67 -6
- package/bin/models/cost-tracker.js +104 -6
- package/bin/models/model-client.js +6 -1
- package/bin/revops/debt-monitor.js +57 -13
- package/bin/security/trust-gate-hook.js +50 -6
- package/bin/skill-validator.js +6 -1
- package/bin/skills-builder/skill-scorer.js +46 -6
- package/bin/updater/self-update.js +6 -1
- package/bin/updater/version-comparator.js +21 -1
- package/bin/utils/mindforge-version.js +99 -0
- package/bin/utils/redact-secrets.js +106 -0
- package/bin/validate-config.js +42 -2
- package/bin/wizard/setup-wizard.js +4 -1
- package/bin/wizard/theme.js +9 -1
- package/changelogs/index.json +11 -9
- package/changelogs/v11.9.3.md +195 -0
- package/docs/References/config-reference.md +5 -2
- package/docs/References/sdk-api.md +1 -1
- package/docs/Templates/Codebase/architecture.md +1 -1
- package/docs/commands-reference.md +4 -5
- package/docs/faq.md +25 -5
- package/docs/getting-started.md +3 -3
- package/docs/sdk-reference.md +15 -7
- package/docs/troubleshooting.md +10 -6
- package/docs/user-guide.md +14 -14
- package/examples/sdk-integration/README.md +1 -1
- package/package.json +7 -3
- package/subagents/.claude-plugin/marketplace.json +1 -1
- package/bin/dashboard/approval-handler.js +0 -136
package/.agent/CLAUDE.md
CHANGED
|
@@ -25,25 +25,37 @@ You are a **Dynamic Multi-Agent Swarm (Agentic Mesh)**. Your mission is to execu
|
|
|
25
25
|
|
|
26
26
|
## 🛠️ CORE PROTOCOLS (The "How")
|
|
27
27
|
|
|
28
|
+
> **These are protocols you follow, not modules you call.** `SwarmController`, `PersonaFactory`
|
|
29
|
+
> and `WaveExecutor` are role names in the specs under `.mindforge/engine/`, not importable code —
|
|
30
|
+
> there is no file by any of those names. Every step below is something you do by reasoning and by
|
|
31
|
+
> using your own tools. Where a real executable exists, it is named with its path.
|
|
32
|
+
|
|
28
33
|
### 1. Swarm Dynamic Orchestration (V4)
|
|
29
34
|
**IF** task complexity/impact is high **OR** cross-disciplinary logic is required:
|
|
30
|
-
1.
|
|
35
|
+
1. Adopt the swarm-orchestration protocol described in `.mindforge/engine/`.
|
|
31
36
|
2. Spawn task-specific ephemeral specialist cluster (AIEngineering, Security, etc.).
|
|
32
|
-
3.
|
|
33
|
-
4. Execute parallel mesh waves
|
|
37
|
+
3. Load the relevant persona brief from `.mindforge/personas/` before each specialist acts.
|
|
38
|
+
4. Execute parallel mesh waves, consolidating dependent work before independent work.
|
|
34
39
|
5. Consolidate mesh findings into a single `SWARM-SUMMARY`.
|
|
35
40
|
|
|
36
41
|
### 2. The Sharded Memory Loop (SRD)
|
|
37
42
|
**IF** context ≥ 70% **OR** starting a new task:
|
|
38
|
-
1.
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
1. Rotate context per the Tri-Tier strategy (Hot/Warm/Cold) yourself — this is an advisory
|
|
44
|
+
discipline, not an automated step.
|
|
45
|
+
2. Inject only sharded relevant data into the active buffer.
|
|
46
|
+
3. Re-read the current phase's plan rather than carrying stale detail forward.
|
|
41
47
|
|
|
42
48
|
### 3. The Adversarial Decision Loop (ADS)
|
|
43
49
|
**BEFORE** committing any architectural change:
|
|
44
|
-
1. Spawn Red-Team/Blue-Team debate contexts.
|
|
45
|
-
2.
|
|
46
|
-
3. **STOP** if SOUL Score < `[MIN_SOUL_SCORE]` from MINDFORGE.md.
|
|
50
|
+
1. Spawn Red-Team/Blue-Team debate contexts and argue the change against itself.
|
|
51
|
+
2. Score the proposal on impact, leverage, reversibility, effort, risk and cost.
|
|
52
|
+
3. **STOP** if the resulting SOUL Score < `[MIN_SOUL_SCORE]` from MINDFORGE.md.
|
|
53
|
+
|
|
54
|
+
ADS is a reasoning protocol you run, not a command. Do not look for a script: measured, the two
|
|
55
|
+
scripts this section used to name (soul-engine.js and shard-controller.js) do not exist
|
|
56
|
+
anywhere in the package. The nearest real implementation, `runADSSynthesis()` in
|
|
57
|
+
`bin/review/ads-engine.js`, is a library with a single internal caller, exposes no CLI, and takes
|
|
58
|
+
`{phaseNum, goal, context, sessionId}` rather than a diff, so it cannot be invoked here either.
|
|
47
59
|
|
|
48
60
|
### 4. Standard Extended Protocols (Quality Gates)
|
|
49
61
|
**MANDATORY**: For specific workflows, activate the corresponding `_extended` protocol:
|
|
@@ -78,18 +90,26 @@ Prioritize based on `[REACTIVE_MODE]` in MINDFORGE.md. These are the **Quality g
|
|
|
78
90
|
- [ ] **AgRevOps Check**: Verify ROI trends and Security Health Score via `/api/revops`.
|
|
79
91
|
- [ ] **PLAN-FIRST RULE**: Never code without a verified XML plan.
|
|
80
92
|
- [ ] **Verify First**: Never task-complete without successful `<verify>` output.
|
|
81
|
-
- [ ] **Audit Always**: Write a JSONL entry for every significant session event.
|
|
93
|
+
- [ ] **Audit Always**: Write a JSONL entry for every significant session event. The result is a hash-chained append-only audit log (SHA-256 back-links): each entry sets `previous_hash` to the prior entry's `_hash`.
|
|
82
94
|
|
|
83
95
|
---
|
|
84
96
|
|
|
85
97
|
## ⚡ COMMAND SUITE
|
|
86
98
|
|
|
99
|
+
Every entry below has a backing command file, checked by `tests/protocol-claims.test.js`. Two that
|
|
100
|
+
did not (the brainstorming and history entries) are gone: neither existed in
|
|
101
|
+
`.claude/commands/mindforge/` or `.agent/mindforge/` (both hold exactly 221 files, so this was not
|
|
102
|
+
a mirror gap), and neither had a near-match to correct to. Temporal history is reachable, just not
|
|
103
|
+
as a slash command, so it is named as what it actually is.
|
|
104
|
+
|
|
87
105
|
- `/mindforge:next` — Primary auto-discovery.
|
|
88
106
|
- `/mindforge:auto` — Reactive engine start.
|
|
89
|
-
- `/mindforge:
|
|
90
|
-
- `/mindforge:
|
|
107
|
+
- `/mindforge:plan-phase` — Plan the next phase before writing code.
|
|
108
|
+
- `/mindforge:verify-phase` — Verify the phase against its plan.
|
|
91
109
|
- `/mindforge:status` — Project health & sharding state.
|
|
92
110
|
- `/mindforge:audit` — Day 4 governance access.
|
|
111
|
+
- `mindforge temporal <status|cleanup|inject>` — reasoning-history access (a CLI command, not a
|
|
112
|
+
slash command).
|
|
93
113
|
|
|
94
114
|
---
|
|
95
115
|
|
|
@@ -109,7 +129,11 @@ Adopt the Principal AI persona. Be instruction-dense, unambiguous, and architect
|
|
|
109
129
|
|
|
110
130
|
**Source of Truth Hierarchy**:
|
|
111
131
|
|
|
112
|
-
1. SOUL.md (Sovereign Identity — Behavioral OS)
|
|
132
|
+
1. SOUL.md (Sovereign Identity — Behavioral OS). **Not shipped in the package**, and generated
|
|
133
|
+
locally rather than installed: `bin/memory/identity-synthesizer.js` creates and evolves it. If
|
|
134
|
+
your project has no SOUL.md, this entry is vacant and authority passes to MINDFORGE.md — do not
|
|
135
|
+
infer its contents. The prompt-defense baseline it carries is reproduced verbatim at the top of
|
|
136
|
+
this file, so that part reaches you either way.
|
|
113
137
|
2. MINDFORGE.md (Parameter Registry)
|
|
114
138
|
3. .agent/CLAUDE.md (Protocols)
|
|
115
139
|
4. `.mindforge/` (Framework Binary Logic)
|
|
@@ -486,34 +486,73 @@ function checkCommand(input) {
|
|
|
486
486
|
/**
|
|
487
487
|
* Extract the command string from hook input (JSON or plain text).
|
|
488
488
|
*/
|
|
489
|
+
/**
|
|
490
|
+
* Extract the command, and report whether input that LOOKED like JSON failed to parse.
|
|
491
|
+
*
|
|
492
|
+
* The parse-failure fallback used to `return trimmed` — scan the raw text for the bypass flag
|
|
493
|
+
* instead. That is a reasonable defence for genuinely non-JSON input, but on a TRUNCATED JSON
|
|
494
|
+
* payload it becomes a bypass: the flag is simply absent from the surviving prefix, the scan finds
|
|
495
|
+
* nothing, and the hook approves. Callers now block instead, matching trust-gate-hook.js, which
|
|
496
|
+
* prints "parse error (BLOCKING)" and exits 2 — and which is why trust-gate was NOT bypassable
|
|
497
|
+
* this way while this hook was.
|
|
498
|
+
*
|
|
499
|
+
* @returns {{command: string, jsonParseFailed: boolean}}
|
|
500
|
+
*/
|
|
489
501
|
function extractCommand(rawInput) {
|
|
490
502
|
const trimmed = rawInput.trim();
|
|
491
|
-
if (!trimmed.startsWith('{')) return trimmed;
|
|
503
|
+
if (!trimmed.startsWith('{')) return { command: trimmed, jsonParseFailed: false };
|
|
492
504
|
|
|
493
505
|
try {
|
|
494
506
|
const parsed = JSON.parse(trimmed);
|
|
495
|
-
if (typeof parsed !== 'object' || parsed === null)
|
|
507
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
508
|
+
return { command: trimmed, jsonParseFailed: false };
|
|
509
|
+
}
|
|
496
510
|
|
|
497
511
|
// Claude Code format: { tool_input: { command: "..." } }
|
|
498
512
|
const cmd = parsed.tool_input?.command;
|
|
499
|
-
if (typeof cmd === 'string') return cmd;
|
|
513
|
+
if (typeof cmd === 'string') return { command: cmd, jsonParseFailed: false };
|
|
500
514
|
|
|
501
515
|
// Generic JSON formats
|
|
502
516
|
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
|
|
503
|
-
if (typeof parsed[key] === 'string') return parsed[key];
|
|
517
|
+
if (typeof parsed[key] === 'string') return { command: parsed[key], jsonParseFailed: false };
|
|
504
518
|
}
|
|
505
519
|
|
|
506
|
-
return trimmed;
|
|
520
|
+
return { command: trimmed, jsonParseFailed: false };
|
|
507
521
|
} catch {
|
|
508
|
-
return trimmed;
|
|
522
|
+
return { command: trimmed, jsonParseFailed: true };
|
|
509
523
|
}
|
|
510
524
|
}
|
|
511
525
|
|
|
512
526
|
/**
|
|
513
527
|
* Exportable run() for in-process execution via run-with-flags.js.
|
|
514
528
|
*/
|
|
515
|
-
function run(rawInput) {
|
|
516
|
-
|
|
529
|
+
function run(rawInput, options = {}) {
|
|
530
|
+
// A truncated payload cannot be judged: the dispatcher caps stdin at MAX_STDIN, so a caller can
|
|
531
|
+
// push the git command past the cap and the flag never reaches this hook. MEASURED before this
|
|
532
|
+
// guard: `git commit --no-verify` preceded by ~1 MiB of padding scored exit 0 (allowed), while
|
|
533
|
+
// the same command alone scored exit 2. Its sibling mindforge-config-protection.js:66 already
|
|
534
|
+
// refused on truncation; this hook computed the flag and never read it, and run() took only one
|
|
535
|
+
// parameter so `options` never arrived at all.
|
|
536
|
+
if (options.truncated) {
|
|
537
|
+
return {
|
|
538
|
+
exitCode: 2,
|
|
539
|
+
stderr:
|
|
540
|
+
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. Refusing to approve ` +
|
|
541
|
+
'a git command on a truncated payload — the bypass flag this hook looks for may have been ' +
|
|
542
|
+
'cut off. Retry with a smaller payload.',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const { command, jsonParseFailed } = extractCommand(rawInput);
|
|
547
|
+
if (jsonParseFailed) {
|
|
548
|
+
return {
|
|
549
|
+
exitCode: 2,
|
|
550
|
+
stderr:
|
|
551
|
+
'BLOCKED: hook input began with "{" but is not valid JSON, so the command could not be ' +
|
|
552
|
+
'read. Failing closed rather than scanning a partial payload for the bypass flag.',
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
517
556
|
const result = checkCommand(command);
|
|
518
557
|
|
|
519
558
|
if (result.blocked) {
|
|
@@ -530,21 +569,30 @@ module.exports = { run };
|
|
|
530
569
|
|
|
531
570
|
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
|
|
532
571
|
if (require.main === module) {
|
|
572
|
+
// Truncation is tracked here too, and seeded from the dispatcher's env flag. This path had the
|
|
573
|
+
// same bypass as run(): it capped stdin at MAX_STDIN and then judged the surviving prefix,
|
|
574
|
+
// silently dropping whatever came after. Going through run() keeps ONE decision path for both
|
|
575
|
+
// invocation modes rather than two that can drift — the same discipline audit-hash.js uses for
|
|
576
|
+
// its writer and verifier.
|
|
577
|
+
let truncated = /^(1|true|yes)$/i.test(String(process.env.MINDFORGE_HOOK_INPUT_TRUNCATED || ''));
|
|
578
|
+
|
|
533
579
|
process.stdin.setEncoding('utf8');
|
|
534
580
|
process.stdin.on('data', chunk => {
|
|
535
581
|
if (raw.length < MAX_STDIN) {
|
|
536
582
|
const remaining = MAX_STDIN - raw.length;
|
|
537
583
|
raw += chunk.substring(0, remaining);
|
|
584
|
+
if (chunk.length > remaining) truncated = true;
|
|
585
|
+
} else {
|
|
586
|
+
truncated = true;
|
|
538
587
|
}
|
|
539
588
|
});
|
|
540
589
|
|
|
541
590
|
process.stdin.on('end', () => {
|
|
542
|
-
const
|
|
543
|
-
const result = checkCommand(command);
|
|
591
|
+
const output = run(raw, { truncated, maxStdin: MAX_STDIN });
|
|
544
592
|
|
|
545
|
-
if (
|
|
546
|
-
process.stderr.write(
|
|
547
|
-
process.exit(
|
|
593
|
+
if (Number.isInteger(output.exitCode) && output.exitCode !== 0) {
|
|
594
|
+
if (output.stderr) process.stderr.write(output.stderr.endsWith('\n') ? output.stderr : output.stderr + '\n');
|
|
595
|
+
process.exit(output.exitCode);
|
|
548
596
|
}
|
|
549
597
|
|
|
550
598
|
process.stdout.write(raw);
|
|
@@ -62,6 +62,58 @@ function parseInput(inputOrRaw) {
|
|
|
62
62
|
/**
|
|
63
63
|
* Exportable run() for in-process execution via run-with-flags.js.
|
|
64
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* Return the protected path a Bash command would WRITE, or '' if it writes none.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately conservative and pattern-specific rather than clever. This hook is deny-class, so a
|
|
69
|
+
* false positive blocks a legitimate command; and every pattern here was chosen because the protected
|
|
70
|
+
* file appears in an unambiguous write position:
|
|
71
|
+
*
|
|
72
|
+
* > path >> path >| path shell redirect
|
|
73
|
+
* tee ... path writes each named file
|
|
74
|
+
* sed -i ... path edits in place
|
|
75
|
+
* dd of=path writes the output file
|
|
76
|
+
* truncate ... path truncates the named file
|
|
77
|
+
* cp/mv src path path is the DESTINATION only when it is last
|
|
78
|
+
*
|
|
79
|
+
* NOT matched, on purpose: `cat path`, `grep x path`, `git diff path`, `< path`, and
|
|
80
|
+
* `cp path /tmp/backup` — all reads. A pipeline is split on |, ; and && first, so
|
|
81
|
+
* `cat tsconfig.json | grep strict` cannot be mistaken for a write.
|
|
82
|
+
*/
|
|
83
|
+
function bashWriteTarget(command) {
|
|
84
|
+
if (!command) return '';
|
|
85
|
+
const names = [...PROTECTED_FILES].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
86
|
+
if (!names.length) return '';
|
|
87
|
+
const anyName = `(?:${names.join('|')})`;
|
|
88
|
+
// A path token ending in a protected basename, at a word boundary so `my-tsconfig.json` is not
|
|
89
|
+
// mistaken for `tsconfig.json`.
|
|
90
|
+
const PATH = `((?:[^\\s;|&<>]*[/])?${anyName})(?![\\w.-])`;
|
|
91
|
+
|
|
92
|
+
// Normalise `>|` (bash's noclobber override) to `>` BEFORE splitting on pipelines. Splitting on a
|
|
93
|
+
// bare `|` tears `>|` in half — `echo {} >| tsconfig.json` became the segments `echo {} >` and
|
|
94
|
+
// ` tsconfig.json`, so the redirect pattern found nothing and the write was permitted. The
|
|
95
|
+
// segmentation was defeating the operator match it exists to support. `>|` and `>` are the same
|
|
96
|
+
// write as far as this gate is concerned, so collapsing them is exact, not a heuristic.
|
|
97
|
+
const normalised = command.replace(/>\s*\|/g, '>');
|
|
98
|
+
for (const segment of normalised.split(/\|\||&&|[;|\n]/)) {
|
|
99
|
+
const s = segment.trim();
|
|
100
|
+
if (!s) continue;
|
|
101
|
+
const checks = [
|
|
102
|
+
new RegExp(`>>?\\|?\\s*${PATH}`), // redirect
|
|
103
|
+
new RegExp(`\\btee\\b(?:\\s+-\\S+)*\\s+${PATH}`), // tee [flags] path
|
|
104
|
+
new RegExp(`\\bsed\\b[^\\n]*?\\s-i\\S*[^\\n]*?\\s${PATH}`), // sed -i ... path
|
|
105
|
+
new RegExp(`\\bdd\\b[^\\n]*?\\bof=${PATH}`), // dd of=path
|
|
106
|
+
new RegExp(`\\btruncate\\b[^\\n]*?\\s${PATH}`), // truncate ... path
|
|
107
|
+
new RegExp(`\\b(?:cp|mv|install)\\b[^\\n]*?\\s${PATH}\\s*$`), // destination is the LAST token
|
|
108
|
+
];
|
|
109
|
+
for (const re of checks) {
|
|
110
|
+
const m = s.match(re);
|
|
111
|
+
if (m) return m[1];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
|
|
65
117
|
function run(inputOrRaw, options = {}) {
|
|
66
118
|
if (options.truncated) {
|
|
67
119
|
return {
|
|
@@ -74,7 +126,24 @@ function run(inputOrRaw, options = {}) {
|
|
|
74
126
|
}
|
|
75
127
|
|
|
76
128
|
const input = parseInput(inputOrRaw);
|
|
77
|
-
|
|
129
|
+
let filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
|
|
130
|
+
|
|
131
|
+
// A Bash command reaches the same files by a different door.
|
|
132
|
+
//
|
|
133
|
+
// Measured before this: an Edit targeting an existing tsconfig.json returned exit 2, while
|
|
134
|
+
// `echo {} > tsconfig.json` as a Bash command returned exit 0 — and trust-gate permitted it too.
|
|
135
|
+
// The identical outcome was blocked through one tool surface and silently permitted through
|
|
136
|
+
// another, with no warning on the permitted path. That is what made "Non-Bypassable Compliance
|
|
137
|
+
// Gates" unsupportable: not a disabled gate, a gate watching one entrance.
|
|
138
|
+
//
|
|
139
|
+
// Only WRITE intent counts. `cat tsconfig.json`, `grep x tsconfig.json` and
|
|
140
|
+
// `cp tsconfig.json /tmp/backup` are reads of a protected file and must pass — a gate that blocks
|
|
141
|
+
// reading its own protected files would be worse than the hole it closes, and this hook is
|
|
142
|
+
// deny-class, so a false positive blocks the operator's real work.
|
|
143
|
+
if (!filePath) {
|
|
144
|
+
const target = bashWriteTarget(String(input?.tool_input?.command || ''));
|
|
145
|
+
if (target) filePath = target;
|
|
146
|
+
}
|
|
78
147
|
if (!filePath) return { exitCode: 0 };
|
|
79
148
|
|
|
80
149
|
const basename = path.basename(filePath);
|
|
@@ -96,13 +165,23 @@ function run(inputOrRaw, options = {}) {
|
|
|
96
165
|
return { exitCode: 0 };
|
|
97
166
|
}
|
|
98
167
|
|
|
168
|
+
// The deny reason no longer names the env var that turns this gate off.
|
|
169
|
+
//
|
|
170
|
+
// A PreToolUse denial is delivered to the MODEL whose tool call was just refused, so the old
|
|
171
|
+
// message ended by telling the agent it had blocked exactly how to stop being blocked
|
|
172
|
+
// ("disable the config-protection hook temporarily (MINDFORGE_DISABLED_HOOKS)"). A control
|
|
173
|
+
// that hands out its own bypass in the refusal is a suggestion, not a control.
|
|
174
|
+
//
|
|
175
|
+
// The escape hatch still exists and is documented in this file's header for the HUMAN
|
|
176
|
+
// operator, who is the party entitled to use it — and run-with-flags.js now writes
|
|
177
|
+
// "SECURITY GATE DISABLED" to stderr whenever it is used, so exercising it leaves a record.
|
|
99
178
|
return {
|
|
100
179
|
exitCode: 2,
|
|
101
180
|
stderr:
|
|
102
181
|
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
|
103
182
|
'Fix the source code to satisfy linter/formatter/tsconfig rules instead ' +
|
|
104
|
-
'of weakening the config.
|
|
105
|
-
'
|
|
183
|
+
'of weakening the config. A legitimate config change needs a human decision, ' +
|
|
184
|
+
'not a hook bypass — ask the operator.'
|
|
106
185
|
};
|
|
107
186
|
}
|
|
108
187
|
|
|
@@ -127,7 +127,7 @@ process.stdin.on('end', () => {
|
|
|
127
127
|
? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
128
128
|
'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' +
|
|
129
129
|
'MindForge state is already tracked in STATE.md. Inform the user so they can run ' +
|
|
130
|
-
'/mindforge:
|
|
130
|
+
'/mindforge:checkpoint at the next natural stopping point.'
|
|
131
131
|
: `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` +
|
|
132
132
|
'Context is nearly exhausted. Inform the user that context is low and ask how they ' +
|
|
133
133
|
'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// (no active /mindforge: command or Task subagent) and injects an advisory warning.
|
|
6
6
|
//
|
|
7
7
|
// This is a SOFT guard — it advises, not blocks. The edit still proceeds.
|
|
8
|
-
// The warning nudges Claude to use /mindforge:quick or /mindforge:
|
|
8
|
+
// The warning nudges Claude to use /mindforge:quick or /mindforge:do instead of
|
|
9
9
|
// making direct edits that bypass state tracking.
|
|
10
10
|
//
|
|
11
11
|
// Enable via config: hooks.workflow_guard: true (default: false)
|
|
@@ -80,7 +80,7 @@ process.stdin.on('end', () => {
|
|
|
80
80
|
hookEventName: 'PreToolUse',
|
|
81
81
|
additionalContext: `⚠️ WORKFLOW ADVISORY: You're editing ${path.basename(filePath)} directly without a MindForge command. ` +
|
|
82
82
|
'This edit will not be tracked in STATE.md or produce a SUMMARY.md. ' +
|
|
83
|
-
'Consider using /mindforge:
|
|
83
|
+
'Consider using /mindforge:do to route the intent, or /mindforge:quick for a small tracked change, ' +
|
|
84
84
|
'to maintain project state tracking. ' +
|
|
85
85
|
'If this is intentional (e.g., user explicitly asked for a direct edit), proceed normally.'
|
|
86
86
|
}
|
|
@@ -21,11 +21,78 @@
|
|
|
21
21
|
const fs = require('fs');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const { spawnSync } = require('child_process');
|
|
24
|
-
const { isHookEnabled } = require('./lib/hook-flags');
|
|
24
|
+
const { isHookEnabled, getDisabledHookIds, getHookProfile, normalizeId } = require('./lib/hook-flags');
|
|
25
25
|
const { buildPreToolUseAdditionalContext } = require('./lib/pretooluse-visible-output');
|
|
26
26
|
|
|
27
27
|
const MAX_STDIN = 1024 * 1024;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Hooks whose entire job is to DENY. If one of these cannot produce a verdict, the safe answer
|
|
31
|
+
* is "block", because nothing has established that the operation is safe.
|
|
32
|
+
*
|
|
33
|
+
* Measured contract, identical across all three: exit 0 = allow, exit 2 = block. (trust-gate
|
|
34
|
+
* also writes {"decision":"block"} to stdout; the other two write a reason to stderr.) Exit 1 is
|
|
35
|
+
* not in the contract at all — Claude Code does not read it as a denial.
|
|
36
|
+
*
|
|
37
|
+
* Every OTHER hook stays fail-open on purpose. context-monitor, session-init, check-update and
|
|
38
|
+
* instinct-capture are advisory: if telemetry cannot run, nothing about the operation's safety
|
|
39
|
+
* became unknown, and blocking a tool call because a logger failed would be indefensible.
|
|
40
|
+
*/
|
|
41
|
+
const DENY_CLASS = new Set([
|
|
42
|
+
'trust-gate',
|
|
43
|
+
'mindforge-block-no-verify',
|
|
44
|
+
'mindforge-config-protection',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Escape hatch, deliberately loud.
|
|
49
|
+
*
|
|
50
|
+
* Rationale for having one at all: these gates fire on every Bash call for anyone working in a
|
|
51
|
+
* MindForge checkout, so a latent crash in trust-gate would otherwise brick every tool call with
|
|
52
|
+
* no way out but editing hook source. The reviewed risk of fail-closed behaviour was precisely
|
|
53
|
+
* "visible unexplained tool-call denials". This makes the override explicit and auditable rather
|
|
54
|
+
* than leaving the failure mode silent.
|
|
55
|
+
*/
|
|
56
|
+
const FAIL_OPEN_OVERRIDE = process.env.MINDFORGE_HOOK_FAILOPEN === '1';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A hook could not produce a verdict. Decide what that means and exit.
|
|
60
|
+
*
|
|
61
|
+
* Before this, EVERY such path echoed stdin and exited 0 — which Claude Code reads as an explicit
|
|
62
|
+
* ALLOW, indistinguishable from a hook that ran and approved. Measured paths that behaved this
|
|
63
|
+
* way: a script that does not exist, a rejected path traversal (it printed "Path traversal
|
|
64
|
+
* rejected" and then permitted), a run() that threw, a non-integer exitCode, and the top-level
|
|
65
|
+
* catch. The spawn path exited 1 instead, which is out of contract and therefore also permits —
|
|
66
|
+
* that one covers a hook TIMEOUT, where no exit 2 was previously reachable at all.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} hookId
|
|
69
|
+
* @param {string} raw the original payload, echoed only when allowing
|
|
70
|
+
* @param {string} reason
|
|
71
|
+
*/
|
|
72
|
+
function failed(hookId, raw, reason) {
|
|
73
|
+
const isDenyClass = DENY_CLASS.has(hookId);
|
|
74
|
+
|
|
75
|
+
if (isDenyClass && !FAIL_OPEN_OVERRIDE) {
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`[Hook] BLOCKED: ${hookId} could not run, so this operation was not checked — ${reason}\n` +
|
|
78
|
+
`[Hook] ${hookId} is a deny-class gate; failing closed. Fix the hook, or set ` +
|
|
79
|
+
'MINDFORGE_HOOK_FAILOPEN=1 to permit unchecked operations.\n');
|
|
80
|
+
// No stdout: a block carries its reason on stderr (see mindforge-block-no-verify).
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isDenyClass) {
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`[Hook] FAIL-OPEN OVERRIDE: ${hookId} could not run (${reason}) and ` +
|
|
87
|
+
'MINDFORGE_HOOK_FAILOPEN=1 is set, so the operation proceeds UNCHECKED.\n');
|
|
88
|
+
} else {
|
|
89
|
+
process.stderr.write(`[Hook] ${hookId} skipped (advisory) — ${reason}\n`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.stdout.write(raw);
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
29
96
|
function readStdinRaw() {
|
|
30
97
|
return new Promise(resolve => {
|
|
31
98
|
let raw = '';
|
|
@@ -92,16 +159,73 @@ function writeLegacySpawnOutput(raw, result) {
|
|
|
92
159
|
}
|
|
93
160
|
|
|
94
161
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
162
|
+
* The roots this dispatcher can legitimately run under, derived from its OWN location.
|
|
163
|
+
*
|
|
164
|
+
* plugin install <pluginRoot>/scripts/run-with-flags.js -> __dirname/..
|
|
165
|
+
* repo / npx <root>/.agent/hooks/run-with-flags.js -> __dirname/../..
|
|
166
|
+
* installed project <project>/.claude/hooks/run-with-flags.js -> __dirname/../..
|
|
167
|
+
*
|
|
168
|
+
* In every real layout the root is an ancestor of __dirname. That is the property the
|
|
169
|
+
* CLAUDE_PLUGIN_ROOT check below relies on. A new layout must be added here deliberately —
|
|
170
|
+
* failing closed on an unrecognised one is the point.
|
|
171
|
+
*/
|
|
172
|
+
function candidateRoots() {
|
|
173
|
+
return [
|
|
174
|
+
path.resolve(__dirname, '..'),
|
|
175
|
+
path.resolve(__dirname, '..', '..'),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* realpath if the path exists, plain resolve otherwise.
|
|
181
|
+
*
|
|
182
|
+
* This exists to prevent FALSE REJECTIONS, not to block an attack — a correction to my first
|
|
183
|
+
* reading of it. An unrelated directory is rejected by the candidate comparison whether or not it
|
|
184
|
+
* is a symlink, because its resolved path is not a candidate either way. What realpath buys is the
|
|
185
|
+
* converse: a LEGITIMATE root reached by an equivalent path still matches. That happens routinely —
|
|
186
|
+
* a symlinked checkout, or macOS reporting /tmp/x while process paths resolve to /private/tmp/x.
|
|
187
|
+
* Comparing unresolved strings would reject those and silently fall back to the derived root.
|
|
188
|
+
*/
|
|
189
|
+
function canonical(p) {
|
|
190
|
+
try { return fs.realpathSync(path.resolve(p)); } catch { return path.resolve(p); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve the MindForge install root.
|
|
195
|
+
*
|
|
196
|
+
* CLAUDE_PLUGIN_ROOT is honoured ONLY when it names a root this dispatcher could actually be
|
|
197
|
+
* running from. It used to be returned verbatim, which made it a gate-substitution primitive
|
|
198
|
+
* rather than a relocation hint: the dispatcher resolves the hook script relative to this root,
|
|
199
|
+
* so pointing the variable at an attacker-controlled directory makes an arbitrary file execute
|
|
200
|
+
* WITH THE AUTHORITY OF THE SECURITY GATE. Measured against the real trust-gate:
|
|
201
|
+
*
|
|
202
|
+
* unset `rm -rf /` -> exit 2 (denied)
|
|
203
|
+
* CLAUDE_PLUGIN_ROOT=<dir with a stub> `rm -rf /` -> exit 0 (APPROVED by the stub)
|
|
204
|
+
* CLAUDE_PLUGIN_ROOT=/tmp `rm -rf /` -> exit 2 (fails closed: no script there)
|
|
205
|
+
*
|
|
206
|
+
* The third case was already safe once deny-class hooks began failing closed. The second was
|
|
207
|
+
* not, and no amount of fail-closed logic fixes it — the substituted gate returns a clean ALLOW.
|
|
208
|
+
* The existing traversal guard does not help either, because the root it measures against is
|
|
209
|
+
* exactly what moved.
|
|
210
|
+
*
|
|
211
|
+
* Rejection is loud and falls back to the derived root, so a mis-set variable degrades to the
|
|
212
|
+
* correct gate rather than to no gate.
|
|
98
213
|
*/
|
|
99
214
|
function getHookRoot() {
|
|
100
|
-
|
|
101
|
-
|
|
215
|
+
const derived = path.resolve(__dirname, '..', '..');
|
|
216
|
+
const raw = process.env.CLAUDE_PLUGIN_ROOT;
|
|
217
|
+
if (!raw || !raw.trim()) return derived;
|
|
218
|
+
|
|
219
|
+
const requested = canonical(raw.trim());
|
|
220
|
+
if (candidateRoots().some((c) => canonical(c) === requested)) {
|
|
221
|
+
return requested;
|
|
102
222
|
}
|
|
103
|
-
|
|
104
|
-
|
|
223
|
+
|
|
224
|
+
process.stderr.write(
|
|
225
|
+
`[Hook] IGNORING CLAUDE_PLUGIN_ROOT=${raw.trim()} — it is not a root this dispatcher runs ` +
|
|
226
|
+
'from, and honouring it would resolve hook scripts out of an unrelated directory (executing ' +
|
|
227
|
+
`them with the gate's authority). Using ${derived} instead.\n`);
|
|
228
|
+
return derived;
|
|
105
229
|
}
|
|
106
230
|
|
|
107
231
|
async function main() {
|
|
@@ -114,6 +238,23 @@ async function main() {
|
|
|
114
238
|
}
|
|
115
239
|
|
|
116
240
|
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
|
|
241
|
+
// Leave a record. Switching a hook off used to be entirely silent — measured,
|
|
242
|
+
// MINDFORGE_DISABLED_HOOKS=trust-gate gave exit 0 with EMPTY stderr, so a disabled security
|
|
243
|
+
// gate was indistinguishable from a gate that ran and approved, in the log as well as in the
|
|
244
|
+
// exit code. The two reasons are reported separately because they are different events: an
|
|
245
|
+
// explicit opt-out is somebody's decision, while a profile mismatch is ordinary configuration.
|
|
246
|
+
const explicitlyDisabled = getDisabledHookIds().has(normalizeId(hookId));
|
|
247
|
+
if (explicitlyDisabled && DENY_CLASS.has(hookId)) {
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
`[Hook] SECURITY GATE DISABLED: ${hookId} was switched off via MINDFORGE_DISABLED_HOOKS, ` +
|
|
250
|
+
'so this operation ran unchecked.\n');
|
|
251
|
+
} else if (explicitlyDisabled) {
|
|
252
|
+
process.stderr.write(`[Hook] ${hookId} disabled via MINDFORGE_DISABLED_HOOKS.\n`);
|
|
253
|
+
} else {
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
`[Hook] ${hookId} not active for profile ${getHookProfile()} ` +
|
|
256
|
+
`(registered for: ${profilesCsv || 'standard,strict'}).\n`);
|
|
257
|
+
}
|
|
117
258
|
process.stdout.write(raw);
|
|
118
259
|
process.exit(0);
|
|
119
260
|
}
|
|
@@ -122,17 +263,14 @@ async function main() {
|
|
|
122
263
|
const resolvedRoot = path.resolve(hookRoot);
|
|
123
264
|
const scriptPath = path.resolve(hookRoot, relScriptPath);
|
|
124
265
|
|
|
125
|
-
// Prevent path traversal outside the install root
|
|
266
|
+
// Prevent path traversal outside the install root. This branch previously printed
|
|
267
|
+
// "Path traversal rejected" and then exited 0 — announcing an attack signal and permitting it.
|
|
126
268
|
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
|
|
127
|
-
|
|
128
|
-
process.stdout.write(raw);
|
|
129
|
-
process.exit(0);
|
|
269
|
+
failed(hookId, raw, `script path escapes the install root: ${scriptPath}`);
|
|
130
270
|
}
|
|
131
271
|
|
|
132
272
|
if (!fs.existsSync(scriptPath)) {
|
|
133
|
-
|
|
134
|
-
process.stdout.write(raw);
|
|
135
|
-
process.exit(0);
|
|
273
|
+
failed(hookId, raw, `script not found at ${scriptPath}`);
|
|
136
274
|
}
|
|
137
275
|
|
|
138
276
|
// Prefer direct require() when the hook exports run(rawInput). Eliminates one
|
|
@@ -161,10 +299,18 @@ async function main() {
|
|
|
161
299
|
truncated,
|
|
162
300
|
maxStdin: MAX_STDIN
|
|
163
301
|
});
|
|
302
|
+
// A hook that returns an exitCode which is not an integer has produced a result we cannot
|
|
303
|
+
// read. emitHookResult() coerced that to 0, so `{exitCode: '2'}` — a string, e.g. from
|
|
304
|
+
// JSON round-tripping — silently became ALLOW. Absent exitCode is different and legitimate:
|
|
305
|
+
// it means "allow, with a message".
|
|
306
|
+
if (output && typeof output === 'object'
|
|
307
|
+
&& Object.prototype.hasOwnProperty.call(output, 'exitCode')
|
|
308
|
+
&& !Number.isInteger(output.exitCode)) {
|
|
309
|
+
failed(hookId, raw, `run() returned a non-integer exitCode: ${JSON.stringify(output.exitCode)}`);
|
|
310
|
+
}
|
|
164
311
|
process.exit(emitHookResult(raw, output));
|
|
165
312
|
} catch (runErr) {
|
|
166
|
-
|
|
167
|
-
process.stdout.write(raw);
|
|
313
|
+
failed(hookId, raw, `run() threw: ${runErr.message}`);
|
|
168
314
|
}
|
|
169
315
|
process.exit(0);
|
|
170
316
|
}
|
|
@@ -189,19 +335,43 @@ async function main() {
|
|
|
189
335
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
190
336
|
|
|
191
337
|
if (result.error || result.signal || result.status === null) {
|
|
338
|
+
// Covers the 30s spawn TIMEOUT, which arrives as signal SIGTERM with a null status. Exiting 1
|
|
339
|
+
// here meant a timed-out security gate permitted the operation: 1 is not in the 0-allow/
|
|
340
|
+
// 2-block contract, so Claude Code does not read it as a denial.
|
|
192
341
|
const failureDetail = result.error
|
|
193
342
|
? result.error.message
|
|
194
343
|
: result.signal
|
|
195
344
|
? `terminated by signal ${result.signal}`
|
|
196
345
|
: 'missing exit status';
|
|
197
|
-
|
|
198
|
-
|
|
346
|
+
failed(hookId, raw, `child process failed: ${failureDetail}`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!Number.isInteger(result.status)) {
|
|
350
|
+
failed(hookId, raw, `child returned a non-integer status: ${String(result.status)}`);
|
|
199
351
|
}
|
|
200
352
|
|
|
201
|
-
|
|
353
|
+
// Enforce the contract: 0 = allow, 2 = block. Anything else is the hook ERRORING, not deciding.
|
|
354
|
+
// Measured: a hook that throws at module scope exits 1, and 1 is not a denial Claude Code
|
|
355
|
+
// honours — so a crashed security gate was permitting the very operation it exists to check.
|
|
356
|
+
// Advisory hooks keep their status verbatim; only a deny-class gate turns an unreadable result
|
|
357
|
+
// into a block, because only there does "we could not decide" mean "do not proceed".
|
|
358
|
+
if (result.status !== 0 && result.status !== 2) {
|
|
359
|
+
failed(hookId, raw, `child exited ${result.status}, which is outside the 0-allow/2-block contract`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
process.exit(result.status);
|
|
202
363
|
}
|
|
203
364
|
|
|
204
365
|
main().catch(err => {
|
|
366
|
+
// The dispatcher itself failed. hookId is argv[2] — read it directly, because main() may have
|
|
367
|
+
// thrown before assigning anything, and the class decision needs it.
|
|
368
|
+
const hookId = process.argv[2] || '';
|
|
369
|
+
if (DENY_CLASS.has(hookId) && !FAIL_OPEN_OVERRIDE) {
|
|
370
|
+
process.stderr.write(
|
|
371
|
+
`[Hook] BLOCKED: dispatcher failed for ${hookId} — ${err.message}\n` +
|
|
372
|
+
'[Hook] deny-class gate; failing closed. Set MINDFORGE_HOOK_FAILOPEN=1 to override.\n');
|
|
373
|
+
process.exit(2);
|
|
374
|
+
}
|
|
205
375
|
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
|
|
206
376
|
process.exit(0);
|
|
207
377
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
|
-
description:
|
|
2
|
+
description: /mindforge:browse <url | action>
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# /mindforge:browse
|
|
6
6
|
|
|
7
7
|
## Usage
|
|
8
|
-
|
|
8
|
+
`/mindforge:browse <url | action>`
|
|
9
9
|
|
|
10
10
|
## Description
|
|
11
11
|
Controls the persistent MindForge browser daemon.
|
|
@@ -61,7 +61,7 @@ Remove old checkpoints, keeping the last 5.
|
|
|
61
61
|
|
|
62
62
|
## AUDIT linkage
|
|
63
63
|
|
|
64
|
-
Each create/verify optionally writes a
|
|
64
|
+
Each create/verify optionally writes a hash-chained AUDIT.jsonl entry:
|
|
65
65
|
|
|
66
66
|
```json
|
|
67
67
|
{ "event": "checkpoint_created", "name": "core-done", "sha": "abc1234", "tests_pass_rate": 1.0, "coverage": 0.0 }
|
|
@@ -31,7 +31,7 @@ LLM layer judges *whether it matters here* and proposes the highest-leverage fix
|
|
|
31
31
|
|
|
32
32
|
## Step 3 — Report + AUDIT entry
|
|
33
33
|
|
|
34
|
-
Summarize the scorecard, then write a
|
|
34
|
+
Summarize the scorecard, then write a hash-chained AUDIT.jsonl entry:
|
|
35
35
|
|
|
36
36
|
```json
|
|
37
37
|
{
|