@indigoai-us/hq-cli 5.282.0 → 5.284.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/assets/scaffold/.claude/hooks/validate-policy-frontmatter.sh +485 -0
- package/assets/scaffold/core/scripts/eval-trigger.sh +193 -0
- package/assets/scaffold/core/scripts/policy-age-report.sh +156 -0
- package/assets/scaffold/core/scripts/policy-retire.sh +199 -0
- package/assets/scaffold/core/scripts/read-policy-frontmatter.sh +32 -0
- package/dist/command-catalog.generated.d.ts +67 -0
- package/dist/command-catalog.generated.js +83 -0
- package/dist/commands/core-knowledge-prefs.d.ts +10 -0
- package/dist/commands/core-knowledge-prefs.js +125 -0
- package/dist/commands/core-native-policy-flag.d.ts +9 -0
- package/dist/commands/core-native-policy-flag.js +12 -0
- package/dist/commands/core-ontology-native.d.ts +0 -1
- package/dist/commands/core-ontology-native.js +2 -30
- package/dist/commands/core-ontology.d.ts +4 -0
- package/dist/commands/core-ontology.js +8 -3
- package/dist/commands/core-policy-frontmatter.d.ts +8 -0
- package/dist/commands/core-policy-frontmatter.js +52 -0
- package/dist/commands/core-policy.d.ts +15 -0
- package/dist/commands/core-policy.js +982 -0
- package/dist/commands/core.js +14 -1
- package/dist/commands/lanes-shared.d.ts +4 -1
- package/dist/commands/lanes-shared.js +2 -1
- package/dist/commands/lanes.d.ts +8 -0
- package/dist/commands/lanes.js +303 -91
- package/dist/fast-lanes-list-route.d.ts +8 -0
- package/dist/fast-lanes-list-route.js +30 -0
- package/dist/fast-lanes-list.d.ts +3 -0
- package/dist/fast-lanes-list.js +252 -0
- package/dist/fast-scaffold.d.ts +5 -0
- package/dist/fast-scaffold.js +10 -0
- package/dist/index.js +10 -1
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +24 -1
- package/dist/lib/checkpoint-ontology/merge-copies.js +70 -1
- package/dist/lib/lanes/flag-keys.d.ts +3 -0
- package/dist/lib/lanes/flag-keys.js +3 -0
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/index.js +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +9 -1
- package/dist/lib/lanes/outpost-dispatch.js +14 -2
- package/dist/lib/lanes/outpost-hire-cache.d.ts +3 -0
- package/dist/lib/lanes/outpost-hire-cache.js +11 -3
- package/dist/lib/lanes/outpost-snapshot-cache.d.ts +26 -0
- package/dist/lib/lanes/outpost-snapshot-cache.js +215 -0
- package/dist/lib/lanes/store.d.ts +4 -0
- package/dist/lib/lanes/store.js +39 -0
- package/dist/lib/mesh/live/daemon/credentials.js +8 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +1 -0
- package/dist/lib/mesh/live/daemon/work-subscribe.js +143 -5
- package/dist/lib/policy-validator.d.ts +2 -0
- package/dist/lib/policy-validator.js +136 -0
- package/dist/native-fast-route.d.ts +2 -0
- package/dist/native-fast-route.js +6 -2
- package/dist/utils/contribution-table.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.284.0] — 2026-09-29
|
|
6
|
+
- The existing Work Mesh MQTT subscriber can signal agent inbox wakes through a private local marker without persisting MQTT message contents.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- A remote `hq lanes list` read no longer dispatches owner hire summaries,
|
|
11
|
+
capacity probes, or mirror snapshots to other Outposts. Two Outposts that
|
|
12
|
+
each held a lane placed on the other ran `hq lanes list --json` on each other
|
|
13
|
+
without end, which filled both boxes with SSM commands.
|
|
14
|
+
|
|
15
|
+
## [5.283.0] — 2026-09-29
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `hq core ontology merge-copies --variants` merges two-file duplicate entity
|
|
20
|
+
groups whose names differ only in case, spacing or punctuation, keeping the
|
|
21
|
+
spaced name. Behind `checkpoint.ontology.merge-variants` (off by default).
|
|
22
|
+
|
|
5
23
|
## [5.282.0] — 2026-09-29
|
|
6
24
|
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Add native TypeScript handlers for `hq core knowledge-prefs` and `hq core
|
|
28
|
+
policy age-report`, `retire`, and `frontmatter`, gated by the default-off
|
|
29
|
+
`core-native-policy` flag. Bundled shell assets remain available for rollback
|
|
30
|
+
during the first release.
|
|
31
|
+
|
|
7
32
|
### Changed
|
|
8
33
|
|
|
9
34
|
- Pin the third-party package setup action in the fast-route benchmark workflow to its resolved commit.
|
|
35
|
+
- When the default-off `lanes.outpost-snapshot-cache` flag is enabled,
|
|
36
|
+
`hq lanes list` returns the last cached Outpost hire summaries immediately
|
|
37
|
+
and refreshes stale or missing summaries in the background. JSON rows show
|
|
38
|
+
snapshot age and stale/loading markers; text output includes a short stale or
|
|
39
|
+
loading note. `--fresh` waits for current owner summaries. Validated Outpost
|
|
40
|
+
snapshots live for three seconds; failed reads or lock acquisition are not
|
|
41
|
+
cached.
|
|
10
42
|
|
|
11
43
|
### Fixed
|
|
12
44
|
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# validate-policy-frontmatter.sh — PreToolUse (Write, Edit, MultiEdit).
|
|
3
|
+
#
|
|
4
|
+
# Blocks the create/edit of a POLICY file whose RESULTING frontmatter is missing
|
|
5
|
+
# `when:` or `on:`, or whose `when:` is rejected by the canonical boolean
|
|
6
|
+
# trigger grammar.
|
|
7
|
+
# These fields drive just-in-time policy injection (see
|
|
8
|
+
# core/knowledge/public/hq-core/policies-spec.md). Every policy authored or
|
|
9
|
+
# edited must declare both, and malformed expressions must not reach the
|
|
10
|
+
# runtime's degraded compatibility path.
|
|
11
|
+
#
|
|
12
|
+
# For `enforcement: hard` it also enforces the two limits that keep an
|
|
13
|
+
# always-injected, full-text rule from eating the session it is meant to guard:
|
|
14
|
+
# - no unconditional `when:` (a tautology containing `always`) on a REACTIVE
|
|
15
|
+
# event — that outranks policies that genuinely matched. Unconditional
|
|
16
|
+
# rules belong on `on: [SessionStart]`.
|
|
17
|
+
# - a binding body at or under HQ_POLICY_HARD_RULE_MAX_BYTES (default 6144),
|
|
18
|
+
# measured over the same span inject-policy-on-trigger.sh quotes: text
|
|
19
|
+
# after the frontmatter, up to the first archival heading (`## Rationale`,
|
|
20
|
+
# `## Background`, `## Change history`, `## Examples`, `## References`, …).
|
|
21
|
+
# Both are checked only for hard policies; core/scripts/lint-policy-triggers.sh
|
|
22
|
+
# reports the softer cases across the whole tree.
|
|
23
|
+
#
|
|
24
|
+
# Targets: */policies/*.md (core/, companies/*/, repos/*/*/.claude/, personal/).
|
|
25
|
+
# Excludes: README.md and the .claude/audit/ redaction-rule store (those are not
|
|
26
|
+
# trigger-injected policies). The retired `_digest.md` path has no exemption.
|
|
27
|
+
#
|
|
28
|
+
# Advisory-safe: FAILS OPEN (exit 0) on ambiguity about whether a write targets
|
|
29
|
+
# a policy (non-policy paths, unparsable tool input, or no analyzer engine), so
|
|
30
|
+
# it never blocks an unrelated write. Once it has identified a policy with a
|
|
31
|
+
# `when:`, it FAILS CLOSED if the canonical evaluator is unavailable: silently
|
|
32
|
+
# skipping that dependency would admit malformed hard rules. Engines: node first
|
|
33
|
+
# (complex analyzers run on node per the hooks-no-python migration), else a
|
|
34
|
+
# jq/awk port of the frontmatter/resulting-text analyzer. Neither port parses
|
|
35
|
+
# trigger expressions; core/scripts/eval-trigger.sh owns that grammar.
|
|
36
|
+
#
|
|
37
|
+
# HQ_ALLOW_POLICY_NO_TRIGGER is an emergency operator override. It requires
|
|
38
|
+
# explicit human permission; an agent must never set, export, or write it on
|
|
39
|
+
# its own initiative.
|
|
40
|
+
#
|
|
41
|
+
# Exit codes: 0 = allow, 2 = block.
|
|
42
|
+
#
|
|
43
|
+
# Wired in .claude/settings.json PreToolUse (Edit/Write/MultiEdit) and gated by
|
|
44
|
+
# hook-gate.sh under "validate-policy-frontmatter" (all three profiles).
|
|
45
|
+
|
|
46
|
+
set -uo pipefail
|
|
47
|
+
|
|
48
|
+
INPUT="$(cat)"
|
|
49
|
+
|
|
50
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
|
51
|
+
HOOK_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." 2>/dev/null && pwd)"
|
|
52
|
+
HQ_ROOT="${HQ_ROOT:-$HOOK_ROOT}"
|
|
53
|
+
EVAL_TRIGGER="$HQ_ROOT/core/scripts/eval-trigger.sh"
|
|
54
|
+
JQ="$(command -v jq || true)"
|
|
55
|
+
NODE="$(command -v node || true)"
|
|
56
|
+
case "${HQ_HOOK_ENGINE:-}" in
|
|
57
|
+
jq) NODE="" ;;
|
|
58
|
+
node) JQ="" ;;
|
|
59
|
+
esac
|
|
60
|
+
if [ -z "$NODE" ] && [ -z "$JQ" ]; then exit 0; fi # fail open
|
|
61
|
+
|
|
62
|
+
# Slurp the analyzer into a top-level var (NOT a heredoc inside $(...), which
|
|
63
|
+
# bash 3.2 / macOS mis-parses — see hooks-heredoc-syntax.test.sh), then run via
|
|
64
|
+
# `node -e`. Tool JSON is passed through the environment, not stdin/argv.
|
|
65
|
+
JSPROG=''
|
|
66
|
+
IFS= read -r -d '' JSPROG <<'JS' || true
|
|
67
|
+
const fs = require("fs");
|
|
68
|
+
const path = require("path");
|
|
69
|
+
|
|
70
|
+
const allow = () => { console.log("ALLOW"); process.exit(0); };
|
|
71
|
+
|
|
72
|
+
let data;
|
|
73
|
+
try { data = JSON.parse(process.env.HQ_HOOK_INPUT || ""); } catch (e) { allow(); }
|
|
74
|
+
const ti = (data && typeof data === "object" && data.tool_input && typeof data.tool_input === "object")
|
|
75
|
+
? data.tool_input : {};
|
|
76
|
+
const fp = ti.file_path || "";
|
|
77
|
+
if (!fp) allow();
|
|
78
|
+
|
|
79
|
+
const proj = process.env.HQ_PROJECT_DIR || "";
|
|
80
|
+
const p = path.isAbsolute(fp) ? fp : path.normalize(path.join(proj, fp));
|
|
81
|
+
const low = p.toLowerCase().replace(/\\/g, "/");
|
|
82
|
+
|
|
83
|
+
if (!(low.endsWith(".md") && low.includes("/policies/"))) allow();
|
|
84
|
+
const base = low.split("/").pop();
|
|
85
|
+
if (base === "readme.md") allow();
|
|
86
|
+
if (low.includes("/audit/")) allow(); // secret-redaction store, not a policy
|
|
87
|
+
|
|
88
|
+
const readCurrent = () => { try { return fs.readFileSync(p, "utf8"); } catch (e) { return ""; } };
|
|
89
|
+
// literal replace-once; a function replacement so "$&"-style patterns in the
|
|
90
|
+
// new string are never interpreted
|
|
91
|
+
const replaceOnce = (hay, o, n) => (o === "" ? hay : hay.replace(o, () => n));
|
|
92
|
+
|
|
93
|
+
let text = null;
|
|
94
|
+
if (ti.content !== undefined && ti.content !== null) { // Write
|
|
95
|
+
text = String(ti.content);
|
|
96
|
+
} else if (Array.isArray(ti.edits)) { // MultiEdit
|
|
97
|
+
text = readCurrent();
|
|
98
|
+
for (const e of ti.edits) {
|
|
99
|
+
const o = String((e && e.old_string) || ""), n = String((e && e.new_string) || "");
|
|
100
|
+
text = (o === "" && text === "") ? n : replaceOnce(text, o, n);
|
|
101
|
+
}
|
|
102
|
+
} else if ("new_string" in ti) { // Edit
|
|
103
|
+
const cur = readCurrent();
|
|
104
|
+
const o = String(ti.old_string || ""), n = String(ti.new_string || "");
|
|
105
|
+
text = (cur === "" && o === "") ? n : replaceOnce(cur, o, n);
|
|
106
|
+
} else allow();
|
|
107
|
+
|
|
108
|
+
if (text === null) allow();
|
|
109
|
+
|
|
110
|
+
// Analyze a line-ending-normalized COPY (CRLF / lone-CR -> LF): Windows
|
|
111
|
+
// editors produce \r\n and the python original tolerated it via \s*. The
|
|
112
|
+
// edit replay above runs on the RAW text so old_string matching is exact.
|
|
113
|
+
const norm = String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
114
|
+
const m = norm.match(/^\s*---[ \t]*\n([\s\S]*?)\n---[ \t]*(\n|$)/);
|
|
115
|
+
if (!m) { console.log("BLOCK|no-frontmatter"); process.exit(0); }
|
|
116
|
+
const fm = m[1];
|
|
117
|
+
const missing = [];
|
|
118
|
+
const whenLines = [...fm.matchAll(/^[ \t]*when:[ \t]*(.*)$/gm)].map((match) => match[1]);
|
|
119
|
+
if (!whenLines.some((expr) => /\S/.test(expr))) missing.push("when");
|
|
120
|
+
if (!/^[ \t]*on:[ \t]*\S/m.test(fm)) missing.push("on");
|
|
121
|
+
if (missing.length) { console.log("BLOCK|" + missing.join(",")); process.exit(0); }
|
|
122
|
+
|
|
123
|
+
// ── Strictness rules for enforcement: hard ────────────────────────────────
|
|
124
|
+
// A hard policy is injected in FULL TEXT and, when its trigger is loose, on
|
|
125
|
+
// every event. Both cost is paid out of the same context the user is trying to
|
|
126
|
+
// work in, so both are constrained here — at authoring time, where the fix is
|
|
127
|
+
// one line — rather than being silently truncated at injection time.
|
|
128
|
+
const enfMatch = fm.match(/^[ \t]*enforcement:[ \t]*["']?([A-Za-z]+)["']?/m);
|
|
129
|
+
const isHard = enfMatch && enfMatch[1].toLowerCase() === "hard";
|
|
130
|
+
|
|
131
|
+
if (isHard) {
|
|
132
|
+
// (1) `when: always` must not be paired with a reactive event. A tautology on
|
|
133
|
+
// PreToolUse/PostToolUse/UserPromptSubmit/AssistantIntent is ranked as an
|
|
134
|
+
// event-specific match and takes the cap slot of a policy that actually
|
|
135
|
+
// matched. SessionStart is the documented home for unconditional rules.
|
|
136
|
+
// A pure OR-chain containing `always` is a tautology; anything with && or !
|
|
137
|
+
// is conditional and left alone.
|
|
138
|
+
const tautological = whenLines.some((w) => {
|
|
139
|
+
const normalized = w.toLowerCase();
|
|
140
|
+
return !/[&!]/.test(normalized) && /(^|[^A-Za-z0-9_./-])always([^A-Za-z0-9_./-]|$)/.test(normalized);
|
|
141
|
+
});
|
|
142
|
+
const onLine = (fm.match(/^[ \t]*on:[ \t]*(.*)$/m) || [, ""])[1];
|
|
143
|
+
const reactive = ["PreToolUse", "PostToolUse", "UserPromptSubmit", "AssistantIntent"]
|
|
144
|
+
.filter((e) => onLine.includes(e));
|
|
145
|
+
if (tautological && reactive.length) {
|
|
146
|
+
console.log("BLOCK|hard-always-reactive|" + reactive.join(", "));
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// (2) Bound the binding text. Everything from the first archival heading on
|
|
151
|
+
// is history, not rule — the injector already stops there, so measure the
|
|
152
|
+
// same span the agent will actually be made to read.
|
|
153
|
+
const STOP = /^#+[ \t]*(rationale|rationale and context|background|change history|changelog|history|examples?|references?|related|see also|sources?|provenance|evidence)[ \t]*$/;
|
|
154
|
+
const after = norm.slice(m.index + m[0].length);
|
|
155
|
+
const binding = [];
|
|
156
|
+
for (const line of after.split("\n")) {
|
|
157
|
+
if (STOP.test(line.trim().toLowerCase())) break;
|
|
158
|
+
binding.push(line);
|
|
159
|
+
}
|
|
160
|
+
const bytes = Buffer.byteLength(binding.join("\n").trim(), "utf8");
|
|
161
|
+
const max = parseInt(process.env.HQ_POLICY_HARD_RULE_MAX_BYTES || "6144", 10);
|
|
162
|
+
if (Number.isFinite(max) && max > 0 && bytes > max) {
|
|
163
|
+
console.log("BLOCK|hard-too-long|" + bytes + "|" + max);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Leave syntax to the canonical batch evaluator below. Emit one record for
|
|
169
|
+
// every `when:` so duplicate frontmatter keys do not escape validation.
|
|
170
|
+
for (const when of whenLines) console.log("CHECK|" + when);
|
|
171
|
+
JS
|
|
172
|
+
|
|
173
|
+
# Literal replace-once (newline-safe) for the jq/awk fallback engine. Strings
|
|
174
|
+
# cross via ENVIRON, not `awk -v`: -v mangles backslash escapes and BSD/
|
|
175
|
+
# onetrueawk aborts on newlines in -v values (same constraint as
|
|
176
|
+
# inject-policy-on-trigger.sh's HQ_ALREADY).
|
|
177
|
+
replace_once() { # env: R_CUR R_OLD R_NEW -> stdout
|
|
178
|
+
awk 'BEGIN{
|
|
179
|
+
cur=ENVIRON["R_CUR"]; old=ENVIRON["R_OLD"]; new=ENVIRON["R_NEW"]
|
|
180
|
+
if (old=="") { printf "%s", cur; exit }
|
|
181
|
+
i=index(cur, old)
|
|
182
|
+
if (i==0) printf "%s", cur
|
|
183
|
+
else printf "%s%s%s", substr(cur,1,i-1), new, substr(cur,i+length(old))
|
|
184
|
+
}'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# jq/awk port of the node analyzer above — same path filters, same
|
|
188
|
+
# resulting-text semantics (Write content / Edit / MultiEdit replays), same
|
|
189
|
+
# ALLOW / BLOCK|missing contract. Used when node is unavailable.
|
|
190
|
+
analyze_with_jq() {
|
|
191
|
+
local fp path low base kind text cur o n count idx
|
|
192
|
+
fp="$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.file_path // empty' 2>/dev/null || true)"
|
|
193
|
+
[ -n "$fp" ] || { echo ALLOW; return; }
|
|
194
|
+
case "$fp" in /*|[A-Za-z]:*) path="$fp" ;; *) path="$PROJECT_DIR/$fp" ;; esac
|
|
195
|
+
low="$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]' | tr '\\' '/')"
|
|
196
|
+
case "$low" in *.md) : ;; *) echo ALLOW; return ;; esac
|
|
197
|
+
case "$low" in */policies/*) : ;; *) echo ALLOW; return ;; esac
|
|
198
|
+
base="${low##*/}"
|
|
199
|
+
case "$base" in readme.md) echo ALLOW; return ;; esac
|
|
200
|
+
case "$low" in */audit/*) echo ALLOW; return ;; esac
|
|
201
|
+
|
|
202
|
+
kind="$(printf '%s' "$INPUT" | "$JQ" -r 'if (.tool_input.content? != null) then "write" elif ((.tool_input.edits? | type) == "array") then "multi" elif (.tool_input | has("new_string")) then "edit" else "none" end' 2>/dev/null || echo none)"
|
|
203
|
+
case "$kind" in
|
|
204
|
+
write) text="$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.content')" ;;
|
|
205
|
+
edit)
|
|
206
|
+
cur=""; [ -f "$path" ] && cur="$(cat "$path" 2>/dev/null || true)"
|
|
207
|
+
o="$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.old_string // ""')"
|
|
208
|
+
n="$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.new_string // ""')"
|
|
209
|
+
if [ -z "$cur" ] && [ -z "$o" ]; then text="$n"
|
|
210
|
+
else text="$(R_CUR="$cur" R_OLD="$o" R_NEW="$n" replace_once)"; fi
|
|
211
|
+
;;
|
|
212
|
+
multi)
|
|
213
|
+
cur=""; [ -f "$path" ] && cur="$(cat "$path" 2>/dev/null || true)"
|
|
214
|
+
text="$cur"
|
|
215
|
+
count="$(printf '%s' "$INPUT" | "$JQ" -r '.tool_input.edits | length' 2>/dev/null || echo 0)"
|
|
216
|
+
idx=0
|
|
217
|
+
while [ "$idx" -lt "${count:-0}" ]; do
|
|
218
|
+
o="$(printf '%s' "$INPUT" | "$JQ" -r ".tool_input.edits[$idx].old_string // \"\"")"
|
|
219
|
+
n="$(printf '%s' "$INPUT" | "$JQ" -r ".tool_input.edits[$idx].new_string // \"\"")"
|
|
220
|
+
if [ -z "$o" ] && [ -z "$text" ]; then text="$n"
|
|
221
|
+
else text="$(R_CUR="$text" R_OLD="$o" R_NEW="$n" replace_once)"; fi
|
|
222
|
+
idx=$((idx+1))
|
|
223
|
+
done
|
|
224
|
+
;;
|
|
225
|
+
*) echo ALLOW; return ;;
|
|
226
|
+
esac
|
|
227
|
+
|
|
228
|
+
printf '%s' "$text" | awk '
|
|
229
|
+
# normalize line endings (CRLF / stray CR) before structural checks —
|
|
230
|
+
# mirrors the node engine and the python original'"'"'s \s* tolerance
|
|
231
|
+
{ line=$0; sub(/\r$/, "", line); L[NR]=line }
|
|
232
|
+
END{
|
|
233
|
+
i=1
|
|
234
|
+
while (i<=NR && L[i] ~ /^[ \t]*$/) i++
|
|
235
|
+
if (i>NR || L[i] !~ /^[ \t]*---[ \t]*$/) { print "BLOCK|no-frontmatter"; exit }
|
|
236
|
+
i++
|
|
237
|
+
closed=0; w=0; wc=0; o=0; taut=0; onx=""; enf=""
|
|
238
|
+
for (; i<=NR; i++) {
|
|
239
|
+
if (L[i] ~ /^---[ \t]*$/) { closed=1; break }
|
|
240
|
+
if (L[i] ~ /^[ \t]*when:[ \t]*/) {
|
|
241
|
+
wx=L[i]; sub(/^[ \t]*when:[ \t]*/, "", wx)
|
|
242
|
+
wx=tolower(wx)
|
|
243
|
+
whens[++wc]=wx
|
|
244
|
+
if (wx ~ /[^ \t]/) w=1
|
|
245
|
+
# a pure OR-chain containing `always` is a tautology; && / ! make it
|
|
246
|
+
# conditional and it is left alone
|
|
247
|
+
if (wx !~ /[&!]/ && wx ~ /(^|[^A-Za-z0-9_.\/-])always([^A-Za-z0-9_.\/-]|$)/) taut=1
|
|
248
|
+
}
|
|
249
|
+
if (L[i] ~ /^[ \t]*on:[ \t]*[^ \t]/) { o=1; onx=L[i] }
|
|
250
|
+
if (L[i] ~ /^[ \t]*enforcement:[ \t]*[^ \t]/) {
|
|
251
|
+
enf=L[i]; sub(/^[ \t]*enforcement:[ \t]*/, "", enf)
|
|
252
|
+
gsub(/["'"'"']/, "", enf); sub(/[ \t].*$/, "", enf); enf=tolower(enf)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (!closed) { print "BLOCK|no-frontmatter"; exit }
|
|
256
|
+
m=""
|
|
257
|
+
if (!w) m="when"
|
|
258
|
+
if (!o) m=(m=="" ? "on" : m ",on")
|
|
259
|
+
if (m!="") { print "BLOCK|" m; exit }
|
|
260
|
+
if (enf == "hard") {
|
|
261
|
+
# (1) unconditional trigger on a reactive event — see the node engine
|
|
262
|
+
react=""
|
|
263
|
+
split("PreToolUse PostToolUse UserPromptSubmit AssistantIntent", ev, " ")
|
|
264
|
+
for (k=1; k<=4; k++) if (index(onx, ev[k]) > 0) react=react (react=="" ? "" : ", ") ev[k]
|
|
265
|
+
if (taut && react != "") { print "BLOCK|hard-always-reactive|" react; exit }
|
|
266
|
+
|
|
267
|
+
# (2) bound the binding text — the same span the injector will quote
|
|
268
|
+
stop="^#+[ \t]*(rationale|rationale and context|background|change history|changelog|history|examples?|references?|related|see also|sources?|provenance|evidence)[ \t]*$"
|
|
269
|
+
bytes=0
|
|
270
|
+
for (i++; i<=NR; i++) {
|
|
271
|
+
probe=tolower(L[i]); sub(/^[ \t]+/, "", probe); sub(/[ \t]+$/, "", probe)
|
|
272
|
+
if (probe ~ stop) break
|
|
273
|
+
bytes += length(L[i]) + 1
|
|
274
|
+
}
|
|
275
|
+
max=ENVIRON["HQ_POLICY_HARD_RULE_MAX_BYTES"]; if (max=="") max=6144
|
|
276
|
+
if (max+0 > 0 && bytes > max+0) { print "BLOCK|hard-too-long|" bytes "|" max; exit }
|
|
277
|
+
}
|
|
278
|
+
# Grammar validation belongs solely to eval-trigger.sh. Emit every
|
|
279
|
+
# populated when: line so duplicate keys are checked too.
|
|
280
|
+
for (k=1; k<=wc; k++) print "CHECK|" whens[k]
|
|
281
|
+
}'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if [ -n "$NODE" ]; then
|
|
285
|
+
RESULT="$(HQ_HOOK_INPUT="$INPUT" HQ_PROJECT_DIR="$PROJECT_DIR" "$NODE" -e "$JSPROG" 2>/dev/null || echo ALLOW)"
|
|
286
|
+
else
|
|
287
|
+
RESULT="$(analyze_with_jq)"
|
|
288
|
+
fi
|
|
289
|
+
|
|
290
|
+
# The evaluator is deliberately checked only after the frontmatter analyzer
|
|
291
|
+
# identifies a policy with at least one populated `when:`. A missing evaluator
|
|
292
|
+
# must block that policy write: allowing it would recreate the malformed-rule
|
|
293
|
+
# bypass this hook exists to prevent. Non-policy writes remain advisory-safe.
|
|
294
|
+
override_enabled() {
|
|
295
|
+
[ "${HQ_ALLOW_POLICY_NO_TRIGGER:-}" = "1" ] || [ "${HQ_ALLOW_POLICY_NO_TRIGGER:-}" = "true" ]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
block_missing_evaluator() {
|
|
299
|
+
# This is an operator-selected emergency override, deliberately as broad as
|
|
300
|
+
# the existing override below: it permits a policy write even when structural
|
|
301
|
+
# checks would otherwise block. Keeping only presence checks in this path
|
|
302
|
+
# would make the same documented override behave differently based on whether
|
|
303
|
+
# the evaluator happened to be executable, and could still prevent a repair
|
|
304
|
+
# write during an evaluator outage. Never make that degradation silent.
|
|
305
|
+
if override_enabled; then
|
|
306
|
+
cat >&2 <<MSG
|
|
307
|
+
NOTE: HQ_ALLOW_POLICY_NO_TRIGGER override active. Allowing this policy write
|
|
308
|
+
without canonical trigger syntax validation because the evaluator is unavailable:
|
|
309
|
+
${EVAL_TRIGGER}
|
|
310
|
+
MSG
|
|
311
|
+
exit 0
|
|
312
|
+
fi
|
|
313
|
+
cat >&2 <<MSG
|
|
314
|
+
BLOCKED: cannot validate this policy's when: expression because the canonical
|
|
315
|
+
trigger evaluator is unavailable.
|
|
316
|
+
|
|
317
|
+
Checked: ${EVAL_TRIGGER}
|
|
318
|
+
Expected: an executable core/scripts/eval-trigger.sh resolved from HQ_ROOT.
|
|
319
|
+
|
|
320
|
+
This write is blocked fail-closed rather than silently skipping syntax
|
|
321
|
+
validation. Restore that executable (or correct HQ_ROOT / CLAUDE_PROJECT_DIR)
|
|
322
|
+
and retry; allowing the write would permit rules that can never parse or fire.
|
|
323
|
+
MSG
|
|
324
|
+
exit 2
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
case "$RESULT" in
|
|
328
|
+
CHECK\|*)
|
|
329
|
+
[ -x "$EVAL_TRIGGER" ] || block_missing_evaluator
|
|
330
|
+
|
|
331
|
+
CHECK_EXPRESSIONS=()
|
|
332
|
+
check_count=0
|
|
333
|
+
while IFS= read -r check_line; do
|
|
334
|
+
case "$check_line" in
|
|
335
|
+
CHECK\|*)
|
|
336
|
+
CHECK_EXPRESSIONS[check_count]="${check_line#CHECK|}"
|
|
337
|
+
check_count=$((check_count + 1))
|
|
338
|
+
;;
|
|
339
|
+
*)
|
|
340
|
+
# The analyzer's protocol is internal and deterministic. A mixed
|
|
341
|
+
# response after identifying a policy is unsafe to interpret as an
|
|
342
|
+
# allow, so use the same explicit fail-closed diagnostic.
|
|
343
|
+
block_missing_evaluator
|
|
344
|
+
;;
|
|
345
|
+
esac
|
|
346
|
+
done <<EOF
|
|
347
|
+
$RESULT
|
|
348
|
+
EOF
|
|
349
|
+
|
|
350
|
+
[ "$check_count" -gt 0 ] || block_missing_evaluator
|
|
351
|
+
CHECK_OUTPUT="$({
|
|
352
|
+
check_index=0
|
|
353
|
+
while [ "$check_index" -lt "$check_count" ]; do
|
|
354
|
+
id="when-$check_index"
|
|
355
|
+
when="${CHECK_EXPRESSIONS[check_index]}"
|
|
356
|
+
printf '%s\t%s\n' "$id" "$when"
|
|
357
|
+
check_index=$((check_index + 1))
|
|
358
|
+
done
|
|
359
|
+
} | bash "$EVAL_TRIGGER" --check 2>&1)" || block_missing_evaluator
|
|
360
|
+
|
|
361
|
+
check_index=0
|
|
362
|
+
malformed_when_found=0
|
|
363
|
+
bad_when=""
|
|
364
|
+
while IFS=$'\t' read -r checked_id checked_status checked_extra || [ -n "${checked_id:-}${checked_status:-}${checked_extra:-}" ]; do
|
|
365
|
+
[ "$checked_id" = "when-$check_index" ] && [ -z "$checked_extra" ] || block_missing_evaluator
|
|
366
|
+
case "$checked_status" in
|
|
367
|
+
ok) ;;
|
|
368
|
+
malformed)
|
|
369
|
+
malformed_when_found=1
|
|
370
|
+
bad_when="${CHECK_EXPRESSIONS[check_index]}"
|
|
371
|
+
;;
|
|
372
|
+
*) block_missing_evaluator ;;
|
|
373
|
+
esac
|
|
374
|
+
check_index=$((check_index + 1))
|
|
375
|
+
done <<EOF
|
|
376
|
+
$CHECK_OUTPUT
|
|
377
|
+
EOF
|
|
378
|
+
[ "$check_index" = "$check_count" ] || block_missing_evaluator
|
|
379
|
+
if [ "$malformed_when_found" = "1" ]; then
|
|
380
|
+
RESULT="BLOCK|invalid-when|$bad_when"
|
|
381
|
+
else
|
|
382
|
+
RESULT="ALLOW"
|
|
383
|
+
fi
|
|
384
|
+
;;
|
|
385
|
+
esac
|
|
386
|
+
|
|
387
|
+
case "$RESULT" in
|
|
388
|
+
BLOCK*)
|
|
389
|
+
if override_enabled; then
|
|
390
|
+
exit 0
|
|
391
|
+
fi
|
|
392
|
+
reason="${RESULT#BLOCK|}"
|
|
393
|
+
case "$reason" in
|
|
394
|
+
hard-always-reactive*)
|
|
395
|
+
events="${reason#hard-always-reactive|}"
|
|
396
|
+
cat >&2 <<MSG
|
|
397
|
+
BLOCKED: an enforcement: hard policy pairs an unconditional \`when:\` with a
|
|
398
|
+
reactive event (${events}).
|
|
399
|
+
|
|
400
|
+
\`when: always\` is TRUE for every command, prompt, and message. On a reactive
|
|
401
|
+
event the injector ranks that as an event-specific match, so it takes the
|
|
402
|
+
session cap slot of a policy that genuinely matched the thing you are doing —
|
|
403
|
+
the loose trigger crowds out the precise one.
|
|
404
|
+
|
|
405
|
+
Pick one:
|
|
406
|
+
when: always + on: [SessionStart] # ambient governance rule
|
|
407
|
+
when: <real signal> + on: [PreToolUse, ...] # e.g. deploy || publish
|
|
408
|
+
|
|
409
|
+
Name the word(s) that actually appear when the rule is relevant. See
|
|
410
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
411
|
+
|
|
412
|
+
(The HQ_ALLOW_POLICY_NO_TRIGGER override requires explicit human permission.
|
|
413
|
+
An agent must never set, export, or write it on its own initiative.)
|
|
414
|
+
MSG
|
|
415
|
+
exit 2
|
|
416
|
+
;;
|
|
417
|
+
hard-too-long*)
|
|
418
|
+
rest="${reason#hard-too-long|}"
|
|
419
|
+
cat >&2 <<MSG
|
|
420
|
+
BLOCKED: enforcement: hard policy body is ${rest%%|*} bytes; the limit is ${rest##*|}.
|
|
421
|
+
|
|
422
|
+
A hard policy is injected VERBATIM into the session, so its length is a
|
|
423
|
+
recurring context cost for every session it fires in. Keep the binding rule
|
|
424
|
+
tight and move the reasoning out of the injected span: everything from the
|
|
425
|
+
first \`## Rationale\` / \`## Background\` / \`## Change history\` /
|
|
426
|
+
\`## Examples\` / \`## References\` heading onward is NOT counted and NOT
|
|
427
|
+
injected, so long-form context belongs there.
|
|
428
|
+
|
|
429
|
+
If the rule genuinely needs more than that, either split it into separate
|
|
430
|
+
policies with distinct triggers, or raise HQ_POLICY_HARD_RULE_MAX_BYTES in
|
|
431
|
+
.claude/settings.local.json "env".
|
|
432
|
+
|
|
433
|
+
(The HQ_ALLOW_POLICY_NO_TRIGGER override requires explicit human permission.
|
|
434
|
+
An agent must never set, export, or write it on its own initiative.)
|
|
435
|
+
MSG
|
|
436
|
+
exit 2
|
|
437
|
+
;;
|
|
438
|
+
esac
|
|
439
|
+
if [ "${reason%%|*}" = "invalid-when" ]; then
|
|
440
|
+
when_expression="${reason#invalid-when|}"
|
|
441
|
+
cat >&2 <<MSG
|
|
442
|
+
BLOCKED: policy file has a malformed when: trigger expression:
|
|
443
|
+
|
|
444
|
+
when: ${when_expression}
|
|
445
|
+
|
|
446
|
+
The canonical trigger evaluator rejected it. A space is NOT an AND, and quoted
|
|
447
|
+
phrases are NOT tokens. Use explicit boolean operators instead:
|
|
448
|
+
"pull request" -> (pull && request)
|
|
449
|
+
"force-push" -> force-push
|
|
450
|
+
|
|
451
|
+
Use only identifiers joined by the documented boolean grammar:
|
|
452
|
+
when: <identifier> # e.g. always | git | /deep-plan
|
|
453
|
+
when: <expr> && <expr> # AND
|
|
454
|
+
when: <expr> || <expr> # OR
|
|
455
|
+
when: ! <expr> # NOT
|
|
456
|
+
when: ( <expr> ) # grouping
|
|
457
|
+
Identifiers may contain letters, digits, _, ., /, and internal -. Quotes,
|
|
458
|
+
YAML block scalars, adjacent identifiers without an operator, and other
|
|
459
|
+
punctuation are not valid. See core/knowledge/public/hq-core/policies-spec.md
|
|
460
|
+
("Trigger Expressions"). Fix the expression, then retry.
|
|
461
|
+
|
|
462
|
+
(The HQ_ALLOW_POLICY_NO_TRIGGER override requires explicit human permission.
|
|
463
|
+
An agent must never set, export, or write it on its own initiative.)
|
|
464
|
+
MSG
|
|
465
|
+
exit 2
|
|
466
|
+
fi
|
|
467
|
+
cat >&2 <<MSG
|
|
468
|
+
BLOCKED: policy file is missing required trigger frontmatter (missing: ${reason}).
|
|
469
|
+
|
|
470
|
+
Every policy under */policies/ must declare BOTH:
|
|
471
|
+
when: <expression> # e.g. always | git && push | deploy || share
|
|
472
|
+
on: [<events>] # any of PreToolUse, PostToolUse, UserPromptSubmit, AssistantIntent, SessionStart
|
|
473
|
+
These drive just-in-time policy injection. See
|
|
474
|
+
core/knowledge/public/hq-core/policies-spec.md ("Trigger Expressions").
|
|
475
|
+
Add both fields to the frontmatter, then retry.
|
|
476
|
+
|
|
477
|
+
(The HQ_ALLOW_POLICY_NO_TRIGGER override requires explicit human permission.
|
|
478
|
+
An agent must never set, export, or write it on its own initiative.)
|
|
479
|
+
MSG
|
|
480
|
+
exit 2
|
|
481
|
+
;;
|
|
482
|
+
*)
|
|
483
|
+
exit 0
|
|
484
|
+
;;
|
|
485
|
+
esac
|