navori 0.12.0 → 0.12.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.
- package/dist/assets/core/core-assets/agents/implementer.md +11 -7
- package/dist/assets/core/core-assets/agents/orchestrator.md +1 -1
- package/dist/assets/core/core-assets/agents/publisher.md +8 -0
- package/dist/assets/core/core-assets/agents/reviewer.md +9 -3
- package/dist/assets/core/core-assets/agents/scribe.md +5 -1
- package/dist/assets/core/core-assets/hooks/_partials/bash-outcome.sh +32 -0
- package/dist/assets/core/core-assets/hooks/_partials/review-edit-binding.sh +127 -0
- package/dist/assets/core/core-assets/hooks/implementer-no-markdown.sh +68 -7
- package/dist/assets/core/core-assets/hooks/master-accept-confirm.sh +11 -2
- package/dist/assets/core/core-assets/hooks/model-advisor.sh +95 -8
- package/dist/assets/core/core-assets/hooks/plan-gate.sh +13 -3
- package/dist/assets/core/core-assets/hooks/pr-publisher-confirm.sh +29 -0
- package/dist/assets/core/core-assets/hooks/role-guard.sh +28 -1
- package/dist/assets/core/core-assets/hooks/routing-watch.sh +9 -1
- package/dist/assets/core/core-assets/hooks/session-start-context.sh +14 -0
- package/dist/assets/core/core-assets/managed/orquestacion.md +1 -1
- package/dist/assets/core/core-assets/managed/planificacion.md +9 -5
- package/dist/assets/core/core-assets/master-plan/en/parts.md +87 -0
- package/dist/assets/core/core-assets/master-plan/parts.md +87 -0
- package/dist/assets/core/core-assets/settings/settings-base.json +11 -1
- package/dist/assets/core/core-assets/skills/master-plan.md +19 -11
- package/dist/assets/core/core-assets/skills/plan-advanced.md +10 -8
- package/dist/assets/core/core-assets/skills/plan-simple.md +18 -2
- package/dist/assets/core/core-assets/skills/spec-bootstrap.md +1 -1
- package/dist/index.js +488 -1630
- package/package.json +1 -1
|
@@ -5,14 +5,14 @@ tools: Read, Write, Edit, Glob, Grep, Bash
|
|
|
5
5
|
model: {{models.implementer}}
|
|
6
6
|
effort: {{effort.implementer}}
|
|
7
7
|
maxTurns: 160
|
|
8
|
-
maxWords:
|
|
8
|
+
maxWords: 2669
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# Implementer Agent
|
|
12
12
|
|
|
13
13
|
You execute **a single** task from start to verification. You don't orchestrate, you don't launch other subagents.
|
|
14
14
|
<!-- navori:if scribeOwnsMarkdown -->
|
|
15
|
-
**
|
|
15
|
+
**Markdown has two owners.** You SHALL NOT create or edit any file whose name ends in `.md` or `.mdx` — not a working note, not a spec, not `progress/`, not `docs/`, not the README, not a handoff, not even your own report — with ONE exception: AI doctrine, which is part of the code you change. Doctrine is the agents, skills, managed context and hooks, in their `packages/core/core-assets/{agents,skills,managed,hooks}/` source or in an engine's doctrine folders (`.claude/{agents,skills,context,hooks}/`, `.codex/{agents,hooks}/`, `.agents/skills/`, `.pi/navori/`); write it with Write/Edit/NotebookEdit/apply_patch, never with Bash, and never through a path containing `..`. Edit the SOURCE and run `navori render --apply`; a hand edit to a generated mirror is overwritten by the next render. Every other piece of prose that belongs in the diff (status, specs, docs, README, task checkboxes) goes through `markdownRequests` in your JSON evidence (see "Closing report" below); the `scribe` drafts and applies it in your worktree, on your branch, in a commit of its own.
|
|
16
16
|
<!-- /navori:if -->
|
|
17
17
|
## Protocol
|
|
18
18
|
|
|
@@ -43,9 +43,13 @@ You execute **a single** task from start to verification. You don't orchestrate,
|
|
|
43
43
|
|
|
44
44
|
If `navori gate <kind>` prints no first line `navori gate <kind>: exit <N> — log <path>`, it did not run: run the literal `{{qualityGate.fast}}`. Open the log only on a red.
|
|
45
45
|
|
|
46
|
-
If it fails
|
|
46
|
+
If it fails, fix and rerun; don't return red. You are the single owner of this gate run: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal. If it may outlive Bash's timeout, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run chained steps one by one in the foreground, never background them (<!-- navori:if-not onCodex -->no shell `&`, no `run_in_background`, no `Monitor`<!-- /navori:if-not --><!-- navori:if onCodex -->no shell `&`, no background execution<!-- /navori:if -->). If no chained step fits under any foreground timeout, report `BLOCKED` instead of improvising a background wait. If you can't explain the failure, apply `.claude/skills/debug-failure/SKILL.md` before editing. If the second fix fails identically, use the skill's hypothesis re-check; don't try a third patch.
|
|
47
47
|
5. **UI**: for screen changes, the default evidence is the repo's tests plus a correct diff — **do NOT spin up a browser or dev server automatically**. Visual/browser validation is **optional and strictly on-request**: run it only when the user explicitly asks to check the UI in this prompt, and then drive the repo's browser-automation tool if one is set up (e.g. `playwright-cli`, whose installer ships its own skill).
|
|
48
48
|
6. **No commits** without the `reviewer`'s approval. When you finish, <!-- navori:if-not scribeOwnsMarkdown -->write the report<!-- /navori:if-not --><!-- navori:if scribeOwnsMarkdown -->write your JSON evidence<!-- /navori:if --> and return the reference.
|
|
49
|
+
<!-- navori:if onCodex -->
|
|
50
|
+
|
|
51
|
+
7. **Native producer attestation:** after the final structured handoff is written, run `bun packages/cli/src/index.ts handoff attest <feature> --target {{prTarget}} --json` from the actual target checkout. This records the designated producer through the supported native runtime. If the native role or ownership is missing, leave it pending and report the blocker; never accept caller-supplied actor IDs or claim forensic authorship of every write.
|
|
52
|
+
<!-- /navori:if -->
|
|
49
53
|
<!-- navori:if planTiers -->
|
|
50
54
|
When the encargo opens with `workplan: <feature>`, read `.navori/state/handoffs/workplan_<feature>.json`, run each assigned `A<n>` `command` exactly as written (same string, foreground, no rewrites), then mark progress and report it in `impl_<feature>.json` under `acceptance` (`id`, `command`, `exitCode`, `excerpt`). The routing-watch hook records acceptance evidence only when the host itself ran that exact command; `navori plan update <feature> --progress A<n>=cumplido` is rejected without it and never runs the command for you. A file outside the workplan's files is a blocker to report, not a change to make.
|
|
51
55
|
<!-- /navori:if -->
|
|
@@ -60,13 +64,13 @@ When the encargo opens with `workplan: <feature>`, read `.navori/state/handoffs/
|
|
|
60
64
|
- **No hardcode**: secrets / URLs / endpoints via env vars (`process.env.*`, `import.meta.env.*`, depending on the stack).
|
|
61
65
|
- **No `console.log`** in code that will be merged (guard with `import.meta.env.DEV` or the runtime's equivalent).
|
|
62
66
|
- **Zero new errors** introduced by your code in the quality gate tools (vs. baseline) — classify per `verify-before-done`'s Failure attribution, never by diff location alone; see the evidence table below. Returning with any tool red (because of your change) is automatic grounds for `CHANGES_REQUESTED`.
|
|
63
|
-
- **Never mutate or discard
|
|
67
|
+
- **Never mutate or discard shared work**: don't stash, checkout/reset away local changes, clean the tree, or recursively delete scratch files; these hit `ask` and can stall a background agent.
|
|
64
68
|
- **JSDoc** mandatory on public exports and functions >15 lines or with dense conditional logic.
|
|
65
69
|
- **SDD traceability** (only if the feature has `{{sdd.specsDir}}/<feature>/tasks.md`, see `spec-bootstrap`): each `R<n>` in your batch is covered by ≥1 test, and each test references its requirements with a `// Covers: R<n>` comment above the case. Without full traceability the `reviewer` rejects.
|
|
66
70
|
- **Guard/policy coverage** (only if your task introduces or modifies a guard, policy or permission check): your report carries the enumeration, not just the diff — every entry point that mutates the same resource (routes, bulk/admin variants, jobs, scripts) with its `file:line` evidence, each marked covered or excluded with the reason. Locate them with `locate-code`; an entry point you didn't list is one the `reviewer` has to rediscover.
|
|
67
|
-
- If a tool fails
|
|
68
|
-
-
|
|
69
|
-
-
|
|
71
|
+
- If a tool fails unexpectedly (e.g. tsc breaks without a diff), don't improvise: report `Status: BLOCKED` and the reason in `.navori/state/handoffs/impl_<feature>.md`, then stop.
|
|
72
|
+
- Iterate with area tests only (filter by runner path). Run step 4's fast gate at the end, not each iteration; never run full `{{qualityGate.full}}`—that's the reviewer's Pass 2 and may outlive Bash's timeout. A diff-scoped check (`scoped-gate`) is iteration hygiene, never a substitute for step 4.
|
|
73
|
+
- Keep intermediate runs quiet; use verbose output only to diagnose a concrete failure. Record this turn's gate command, exit code, and summary before reporting green.
|
|
70
74
|
|
|
71
75
|
## Restraint (YAGNI)
|
|
72
76
|
|
|
@@ -81,7 +81,7 @@ Once the plan/scope is approved, execute ALL the sub-tasks without pausing to as
|
|
|
81
81
|
2. **Ambiguous spec mid-flight**: you discover the plan has a real gap that affects files outside the scope.
|
|
82
82
|
3. **All sub-tasks complete**: the cycle finished, ready for `publisher`.
|
|
83
83
|
|
|
84
|
-
**Caps, so a loop cannot pass for persistence.** 2 `CHANGES_REQUESTED` cycles on the SAME task
|
|
84
|
+
**Caps, so a loop cannot pass for persistence.** The cap is 2 `CHANGES_REQUESTED` cycles on the SAME task. Two `CHANGES_REQUESTED`: at level 0/1, a fresh `solution_<f>.md` and its challenge before the next dispatch (each pair of rejections needs its own); at level ≥2, escalate to the user, who alone authorizes another round with `navori plan escalation-reset`. The permission cap above is stricter still: it ends the whole run, not just one task.
|
|
85
85
|
|
|
86
86
|
Do not pause between subtasks for permission or routine progress updates. Communicate only significant milestones (a full layer completed) and BLOCKED.
|
|
87
87
|
|
|
@@ -112,6 +112,14 @@ If the brief says `mode: commit-only`, or `navori receipt check` returns `"gateK
|
|
|
112
112
|
|
|
113
113
|
## PR flow
|
|
114
114
|
|
|
115
|
+
<!-- navori:if onCodex -->
|
|
116
|
+
### Codex: pending publication mediation
|
|
117
|
+
|
|
118
|
+
When the Codex `gh pr create` guard denies a canonical request, do not invoke `gh` again or retry through another tool. Prepare the request through the source CLI by sending the proposed tool payload on stdin to `navori codex approval request`; this only records a bounded pending request. Use the returned UUID with `navori codex approval relay <UUID> --cwd <checkout>` to obtain its read-only summary for the principal.
|
|
119
|
+
|
|
120
|
+
Present the repository, base, current head, title, body-file reference and draft flag, together with the UUID and a reconstructed command for the user to review and run themselves. Keep the body in its referenced file; do not paste raw command text or body contents into the request summary. Caller-supplied origin/provenance, JSON, principal assertions, and a mediation claim are not human consent. The result remains pending until an applicable authenticated human/host consent path exists; the agent must not publish or retry the denied effect through another tool. Unsupported request forms remain denied.
|
|
121
|
+
<!-- /navori:if -->
|
|
122
|
+
|
|
115
123
|
1. **Gather context** (curated, not the whole repo). The PR diff is against `{{prTarget}}`:
|
|
116
124
|
- `git log origin/{{prTarget}}..HEAD --oneline` — commits included.
|
|
117
125
|
- `git diff origin/{{prTarget}}...HEAD --stat` — always.
|
|
@@ -4,7 +4,7 @@ description: Strict reviewer — approves or rejects a diff against CLAUDE.md an
|
|
|
4
4
|
tools: Read, Glob, Grep, Bash, Write
|
|
5
5
|
model: {{models.reviewer}}
|
|
6
6
|
effort: {{effort.reviewer}}
|
|
7
|
-
maxWords:
|
|
7
|
+
maxWords: 3320
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Reviewer Agent
|
|
@@ -41,7 +41,10 @@ You are a strict reviewer. Your only function is to **approve or reject**. You d
|
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
`begin` stamps the content identity BEFORE you read the diff, so it must run first.
|
|
44
|
+
<!-- navori:if onCodex -->
|
|
44
45
|
|
|
46
|
+
For Codex, use the source CLI in this actual checkout to run `bun packages/cli/src/index.ts receipt review begin <feature> --target {{prTarget}} --dir .navori/state/handoffs --json` before reading the diff, then retain its nonce. Immediately after a successful begin and before the expensive full gate, run `bun packages/cli/src/index.ts receipt review preflight <feature> --host-cwd <actual-host-checkout> --json`, using the actual host session checkout for `--host-cwd`. This option supplies diagnostic context only; it never overrides or supplies native reviewer identity. If the host or binding is unsupported or incompatible, stop before the full gate and report the preflight diagnostic. Static compatibility does not prove which fields a live native hook payload contains. After deciding the verdict and writing the review artifacts, run `bun packages/cli/src/index.ts receipt review seal <feature> --nonce <nonce> --target {{prTarget}} --dir .navori/state/handoffs --json`, followed by `bun packages/cli/src/index.ts handoff log-review <feature> --dir .navori/state/handoffs`. The native runtime must independently identify the reviewer; the review must bind to the current producer handoff, publication bytes, and target. Missing, stale, unknown, same-producer, or drifted evidence cannot be approved. Never supply free-form actor IDs or claim the begin proves the reviewer read the diff. An unchanged-bytes publication commit may retain correlation without repeating the full gate.
|
|
47
|
+
<!-- /navori:if -->
|
|
45
48
|
A nonzero `behind` count is a hard stop: do not review, approve, or write a
|
|
46
49
|
receipt. A target-only file would otherwise look like a deletion in this
|
|
47
50
|
worktree and the receipt would sign that phantom deletion.
|
|
@@ -62,6 +65,9 @@ Does the diff do EXACTLY what was asked? You don't review style yet.
|
|
|
62
65
|
<!-- navori:if planTiers -->
|
|
63
66
|
- With a workplan: an assigned `A<n>` without evidence in `acceptance`, a file outside the workplan's files without a covering decision, or `navori plan classify <feature> --diff` returning a higher level than declared → `CHANGES_REQUESTED`.
|
|
64
67
|
- With a workplan: run `navori plan check <feature> --json`; every `A<n>` marked `cumplido` without recorded evidence (the routing-watch hook records it only when the host ran the exact `command`) is a finding → `CHANGES_REQUESTED`.
|
|
68
|
+
- `progress-unevidenced` or `progress-evidence-stale` → `CHANGES_REQUESTED`; `progress-unevidenced-accepted` is not a rejection.
|
|
69
|
+
- With phases: check each phase's receipt with `navori receipt gate --feature <feature> --phase <phase>`.
|
|
70
|
+
- The receipt, the gate log and `navori plan escalation-reset` are local attestations without authentication: seatbelts, not boundaries. Codex review independence depends on `CODEX_THREAD_ID`.
|
|
65
71
|
<!-- /navori:if -->
|
|
66
72
|
- Screen changes are reviewed on the **diff + the repo's tests** — browser/visual validation is **not a default gate**. Only when the user explicitly requested a visual check in this task do you confirm it happened; if it was requested and skipped, flag it. Never escalate a screen change to a human just because no browser check ran.
|
|
67
73
|
|
|
@@ -96,12 +102,12 @@ The full gate runs once, on the round that signs; a rejected round never spends
|
|
|
96
102
|
If `navori gate <kind>` prints no first line `navori gate <kind>: exit <N> — log <path>`, it did not run (an old navori prints help and exits 0): run the literal `{{qualityGate.fast}}` or `{{qualityGate.full}}` instead. Read only the verdict.
|
|
97
103
|
|
|
98
104
|
<!-- navori:if onCodex -->
|
|
99
|
-
Read
|
|
105
|
+
Read verdict (exit code and failure count); report only `exit 0` plus summary or failing tail. The publisher reuses fresh evidence for the final diff. You are the single owner of this gate run, correlated to the diff you're reviewing this turn: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal.
|
|
100
106
|
|
|
101
107
|
Start `{{gateRun.full}}` once with the available execution tool, in the foreground, without shell `&` or detached/background execution. Retain the exact returned continuation handle and, if it yields while running, continue polling that same handle as the same reviewer until the process reports its actual final exit status, exit code, and failure count. For `exec_command`, continue only its exact returned `session_id` via `write_stdin` with empty `chars`; for a yielded tool-native cell, use only its matching cell handle and native wait. A yield is neither a timeout nor success. Do not detach, share, transfer, restart or replay the gate. Without the process's actual final exit, exit code, and failure count, the review is incomplete: report `BLOCKED` and write no receipt. An actual timeout, cancellation, rejection, or lost handle also leaves it `BLOCKED` with no receipt. A nonzero final exit or gate failures follow the existing `QUALITY_MISS` path. The full gate runs only on the round about to sign `APPROVED`; a round ending in `CHANGES_REQUESTED` uses the scoped gate and the assigned `A<n>` commands, and runs no full gate.
|
|
102
108
|
<!-- /navori:if -->
|
|
103
109
|
<!-- navori:if-not onCodex -->
|
|
104
|
-
Read
|
|
110
|
+
Read verdict (exit code and failure count); report only `exit 0` plus summary or failing tail. Publisher reuses fresh gate evidence. You are the single owner of this gate run, correlated to the diff you're reviewing this turn: never share it, never poll `pgrep`/`ps`, and a timeout is never a success signal. Run `{{gateRun.full}}` in the foreground with Bash's maximum timeout. If it may exceed that, follow `.claude/skills/verify-before-done/SKILL.md`'s subagent row: run `&&`-chained steps separately in the foreground, never background them (no shell `&`, no `run_in_background`, no `Monitor`). If no chained step fits under any foreground timeout, report `BLOCKED` instead of improvising a background wait. A yield is neither timeout nor success; run evidence must match the final diff being signed and include exit code and failure count. Missing actual process exit, code, or failure count; timeout, cancellation, rejection, or lost handle means `BLOCKED` with no receipt. Nonzero exit or gate failures mean `QUALITY_MISS`. The full gate runs only on the round about to sign `APPROVED`; `CHANGES_REQUESTED` uses the scoped gate and assigned `A<n>` commands only.
|
|
105
111
|
<!-- /navori:if-not -->
|
|
106
112
|
|
|
107
113
|
|
|
@@ -24,7 +24,7 @@ This role is registered ahead of the typed-handoff migration. Until a producer/c
|
|
|
24
24
|
- To create source code, tests, user-authored documentation, or session-state Markdown.
|
|
25
25
|
- When the evidence is missing, malformed, or belongs to another feature: report the named blocker without fabricating an artifact.
|
|
26
26
|
<!-- /navori:if-not --><!-- navori:if scribeOwnsMarkdown -->
|
|
27
|
-
You are the
|
|
27
|
+
You are the author of the status and deliverable Markdown that lands in the diff: handoffs, `progress/`, specs, `docs/`, README and any other human-facing text. The `implementer` writes AI doctrine itself (agents, skills, managed context and hooks, in their `core-assets` source or an engine's doctrine folders) with the edit tools and `navori render --apply`; never rewrite it from a `markdownRequests` entry unless the request names that path. Two jobs, in this order when both apply: **render** a producer's JSON evidence into its prescribed Markdown handoff (R5), and **draft** the prose any `markdownRequests` entry asks for (R7) — reading the repo as needed for accuracy, never taking a design decision the request doesn't state. Preserve the producer's feature identity, status, evidence, files and verification exactly; add no claim the evidence doesn't carry.
|
|
28
28
|
|
|
29
29
|
## Preflight the handoff
|
|
30
30
|
|
|
@@ -40,6 +40,10 @@ The `implementer` (or another JSON-handoff producer) leaves `.navori/state/hando
|
|
|
40
40
|
## Apply markdownRequests (R7)
|
|
41
41
|
|
|
42
42
|
For every entry, draft the prose from its `intent` and `evidence` — read the surrounding file for tone and format, but never invent a decision the request doesn't state; an ambiguous intent is a blocker to report, not a guess to make. Apply it in the producer's own worktree and branch, touching ONLY the listed `path`s, and commit it yourself — a commit separate from the producer's.
|
|
43
|
+
<!-- navori:if onCodex -->
|
|
44
|
+
|
|
45
|
+
After the requested prose and every official derived render are final, run `bun packages/cli/src/index.ts handoff attest <feature> --target {{prTarget}} --json` from the producer's actual checkout. This records the designated scribe through the supported native runtime. Preserve the producer JSON byte-for-byte, including its hash; do not claim forensic authorship of every write. If native role or ownership is missing, leave it pending and report the blocker. Do not commit before the reviewer approves.
|
|
46
|
+
<!-- /navori:if -->
|
|
43
47
|
|
|
44
48
|
## When NOT to trigger
|
|
45
49
|
|
|
@@ -278,6 +278,38 @@ navori_bash_codex_outcome() {
|
|
|
278
278
|
return 0
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
# Codex 0.162.1 observed CommandExecution: argv, file-URL cwd and completed exit.
|
|
282
|
+
# Missing effective cwd/command or an uncorrelated item never produces evidence.
|
|
283
|
+
navori_codex_acceptance_payload() {
|
|
284
|
+
NV_CODEX_PAYLOAD="$payload" node -e '
|
|
285
|
+
const fs = require("node:fs");
|
|
286
|
+
const { fileURLToPath } = require("node:url");
|
|
287
|
+
try {
|
|
288
|
+
const p = JSON.parse(process.env.NV_CODEX_PAYLOAD);
|
|
289
|
+
if (!p.tool_use_id || !p.transcript_path || p.is_interrupt || p.tool_input?.run_in_background) process.exit(0);
|
|
290
|
+
const fd = fs.openSync(p.transcript_path, "r");
|
|
291
|
+
let lines;
|
|
292
|
+
try {
|
|
293
|
+
const size = fs.fstatSync(fd).size;
|
|
294
|
+
const len = Math.min(size, 1048576);
|
|
295
|
+
const b = Buffer.alloc(len);
|
|
296
|
+
fs.readSync(fd, b, 0, len, size-len);
|
|
297
|
+
lines = b.toString("utf8").split("\n");
|
|
298
|
+
} finally { fs.closeSync(fd); }
|
|
299
|
+
for (let i = lines.length-1; i >= 0; i--) {
|
|
300
|
+
let r; try { r = JSON.parse(lines[i]); } catch { continue; }
|
|
301
|
+
const item = r?.payload?.item;
|
|
302
|
+
if (r?.type !== "event_msg" || r?.payload?.type !== "item_completed" || item?.type !== "CommandExecution" || item.id !== p.tool_use_id) continue;
|
|
303
|
+
if (item.exit_code !== 0 || item.status !== "completed" || typeof item.cwd !== "string") break;
|
|
304
|
+
if (!Array.isArray(item.command) || item.command.length !== 3 || !["-lc", "-c"].includes(item.command[1]) || item.command[2] !== p.tool_input?.command) break;
|
|
305
|
+
const cwd = item.cwd.startsWith("file:") ? fileURLToPath(item.cwd) : item.cwd;
|
|
306
|
+
if (!cwd.startsWith("/") || /[\r\n]/.test(cwd)) break;
|
|
307
|
+
process.stdout.write(JSON.stringify({...p, cwd}));
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
} catch {}'
|
|
311
|
+
}
|
|
312
|
+
|
|
281
313
|
# Called by `routing-watch.sh` once it knows the tool is `Bash`, and only under
|
|
282
314
|
# `claude-post-tool-use` (Codex fires PostToolUse on failure too, so no signal).
|
|
283
315
|
navori_bash_success_lane() {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Versioned reader shared with the CLI preflight. Node builtins only; no CLI dependency.
|
|
2
|
+
# navori:review-edit-binding/v1
|
|
3
|
+
nv_review_edit_binding() {
|
|
4
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
5
|
+
printf '%s\n' 'node-unavailable'
|
|
6
|
+
return 1
|
|
7
|
+
fi
|
|
8
|
+
node - "$@" <<'NV_REVIEW_EDIT_BINDING'
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const path = require('node:path');
|
|
11
|
+
const cp = require('node:child_process');
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
const [hostInput, thread, role, prefixes, ...outputs] = process.argv.slice(2);
|
|
14
|
+
const neutral = '.navori/state/handoffs';
|
|
15
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
16
|
+
const slug = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
|
+
const hex = /^[a-f0-9]{64}$/;
|
|
18
|
+
const deny = (reason) => { throw new Error(reason); };
|
|
19
|
+
const hash = (raw) => crypto.createHash('sha256').update(raw).digest('hex');
|
|
20
|
+
function git(cwd, ...args) {
|
|
21
|
+
const r = cp.spawnSync('git', ['-c', 'core.fsmonitor=false', ...args], {
|
|
22
|
+
cwd, encoding: 'utf8', timeout: 2000, maxBuffer: 1048576,
|
|
23
|
+
});
|
|
24
|
+
if (r.status !== 0) deny('git-unavailable');
|
|
25
|
+
return r.stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
/** Reject lexical traversal and every symlink, including a not-yet-existing leaf's ancestors. */
|
|
28
|
+
function physical(input) {
|
|
29
|
+
if (typeof input !== 'string' || !path.isAbsolute(input) || input.split(/[\\/]/).includes('..')) deny('unsafe-path');
|
|
30
|
+
let cursor = path.parse(input).root;
|
|
31
|
+
for (const part of input.slice(cursor.length).split(path.sep).filter(Boolean)) {
|
|
32
|
+
cursor = path.join(cursor, part);
|
|
33
|
+
try { if (fs.lstatSync(cursor).isSymbolicLink()) deny('symlink-path'); }
|
|
34
|
+
catch (e) { if (!(e instanceof Error) || !('code' in e) || e.code !== 'ENOENT') throw e; }
|
|
35
|
+
}
|
|
36
|
+
return path.resolve(input);
|
|
37
|
+
}
|
|
38
|
+
/** Read one exact file; index/stamp reads have individual 64 KiB bounds. */
|
|
39
|
+
function json(file, bounded = false) {
|
|
40
|
+
physical(file);
|
|
41
|
+
const fd = fs.openSync(file, 'r');
|
|
42
|
+
try {
|
|
43
|
+
if (bounded) {
|
|
44
|
+
if (!fs.fstatSync(fd).isFile() || fs.fstatSync(fd).size > 65536) deny('oversized-binding');
|
|
45
|
+
const buf = Buffer.alloc(65537);
|
|
46
|
+
const count = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
47
|
+
if (count > 65536) deny('oversized-binding');
|
|
48
|
+
return JSON.parse(buf.subarray(0, count).toString('utf8'));
|
|
49
|
+
}
|
|
50
|
+
return JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
51
|
+
} finally { fs.closeSync(fd); }
|
|
52
|
+
}
|
|
53
|
+
function checkout(cwd) {
|
|
54
|
+
physical(cwd);
|
|
55
|
+
return fs.realpathSync(git(cwd, 'rev-parse', '--show-toplevel'));
|
|
56
|
+
}
|
|
57
|
+
function identity(cwd) {
|
|
58
|
+
return {
|
|
59
|
+
checkout: checkout(cwd),
|
|
60
|
+
gitDir: fs.realpathSync(git(cwd, 'rev-parse', '--path-format=absolute', '--git-dir')),
|
|
61
|
+
commonDir: fs.realpathSync(git(cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir')),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function registered(host, target) {
|
|
65
|
+
const entries = git(host, 'worktree', 'list', '--porcelain', '-z').split('\0');
|
|
66
|
+
return entries.includes(`worktree ${host}`) && entries.includes(`worktree ${target}`);
|
|
67
|
+
}
|
|
68
|
+
/** Bind one target/feature to the native event thread, current owner and two exact outputs. */
|
|
69
|
+
function validate(host, output) {
|
|
70
|
+
physical(output);
|
|
71
|
+
const match = /^(.*)\/\.navori\/state\/handoffs\/review_([^/]+)\.(md|json)$/.exec(output);
|
|
72
|
+
if (!match || !slug.test(match[2])) deny('not-review-output');
|
|
73
|
+
const [, target, feature] = match;
|
|
74
|
+
const actual = identity(target);
|
|
75
|
+
if (actual.checkout !== target || actual.commonDir !== host.commonDir ||
|
|
76
|
+
!registered(host.checkout, target)) deny('unregistered-checkout');
|
|
77
|
+
const state = path.join(target, neutral);
|
|
78
|
+
const index = json(path.join(state, `review_${feature}.${thread}.edit.json`), true);
|
|
79
|
+
if (!index || Object.keys(index).sort().join(',') !== 'feature,nonce,thread,v' ||
|
|
80
|
+
index.v !== 1 || index.feature !== feature || index.thread !== thread ||
|
|
81
|
+
!uuid.test(index.nonce)) deny('invalid-index');
|
|
82
|
+
const stamp = json(path.join(state, `review_${feature}.${index.nonce}.begin.json`), true);
|
|
83
|
+
const b = stamp.editBinding;
|
|
84
|
+
const a = stamp.reviewer;
|
|
85
|
+
if (!b || b.v !== 1 || b.checkout !== target || b.gitDir !== actual.gitDir ||
|
|
86
|
+
b.commonDir !== actual.commonDir || b.stateDir !== neutral || typeof b.target !== 'string' ||
|
|
87
|
+
!b.target || stamp.v !== 1 || stamp.alg !== 'navori-content/v1' ||
|
|
88
|
+
stamp.feature !== feature || stamp.nonce !== index.nonce) deny('invalid-binding');
|
|
89
|
+
if (!a || a.host !== 'codex' || a.version !== '0.162.1' || a.role !== 'reviewer' ||
|
|
90
|
+
a.thread !== thread || !uuid.test(a.root) || !uuid.test(a.parent) ||
|
|
91
|
+
!hex.test(a.sourceHash) || checkout(a.sourceCwd) !== host.checkout) deny('wrong-native-actor');
|
|
92
|
+
const owner = json(path.join(state, `ownership_${feature}.json`));
|
|
93
|
+
if (!owner || owner.feature !== feature || owner.cwd !== target || owner.target !== b.target ||
|
|
94
|
+
owner.handoff !== stamp.ownership || !hex.test(stamp.ownership) ||
|
|
95
|
+
owner.fingerprint !== stamp.fingerprint || owner.base !== stamp.base ||
|
|
96
|
+
!Array.isArray(owner.actors) || !owner.actors.length ||
|
|
97
|
+
owner.actors.some((p) => p.host !== 'codex' || p.version !== '0.162.1' ||
|
|
98
|
+
!['implementer', 'scribe'].includes(p.role) || !uuid.test(p.thread) ||
|
|
99
|
+
p.root !== a.root || p.thread === thread)) deny('ownership-mismatch');
|
|
100
|
+
const handoff = path.join(state, `impl_${feature}.json`);
|
|
101
|
+
physical(handoff);
|
|
102
|
+
if (hash(fs.readFileSync(handoff)) !== owner.handoff) deny('handoff-changed');
|
|
103
|
+
return `${target}\0${feature}`;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
if (role !== 'reviewer' || !uuid.test(thread)) deny('native-agent-id-unavailable');
|
|
107
|
+
if (!prefixes.split('\n').some((p) => {
|
|
108
|
+
const prefix = p.replace(/\/$/, '');
|
|
109
|
+
return prefix && `${neutral}/review_output.json`.startsWith(`${prefix}/`);
|
|
110
|
+
})) deny('role-prefix-denied');
|
|
111
|
+
if (!outputs.length) deny('missing-output');
|
|
112
|
+
const host = identity(hostInput);
|
|
113
|
+
const checked = new Set();
|
|
114
|
+
for (const output of outputs) {
|
|
115
|
+
physical(output);
|
|
116
|
+
const match = /^(.*)\/\.navori\/state\/handoffs\/review_([^/]+)\.(md|json)$/.exec(output);
|
|
117
|
+
if (!match) deny('not-review-output');
|
|
118
|
+
const key = `${match[1]}\0${match[2]}`;
|
|
119
|
+
if (!checked.has(key)) checked.add(validate(host, output));
|
|
120
|
+
}
|
|
121
|
+
process.stdout.write('bound-review-output\n');
|
|
122
|
+
} catch (e) {
|
|
123
|
+
process.stdout.write(`${e instanceof Error ? e.message : 'invalid-binding'}\n`);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
}
|
|
126
|
+
NV_REVIEW_EDIT_BINDING
|
|
127
|
+
}
|
|
@@ -37,10 +37,25 @@
|
|
|
37
37
|
# The contract in `implementer.md` is the rule; this catches the slip, not the
|
|
38
38
|
# workaround.
|
|
39
39
|
#
|
|
40
|
+
# AI-DOCTRINE EXCEPTION (spec 0052, R49; decision by Ulises, 2026-10-14). The
|
|
41
|
+
# doctrine of the harness (agents, skills, managed context, hooks) is part of the
|
|
42
|
+
# code the implementer changes: routing it through the scribe cost an
|
|
43
|
+
# implementer -> scribe -> implementer round per doctrine milestone for no gain.
|
|
44
|
+
# So the EDIT TOOLS (Write/Edit/NotebookEdit/every apply_patch path) pass on a
|
|
45
|
+
# `.md`/`.mdx` under the closed prefix list `navori_doctrine_prefixes` below,
|
|
46
|
+
# relative to the repo root. This partly reverts 0030's "no .md at all"; the
|
|
47
|
+
# scribe still owns status Markdown and human-facing deliverables. The Bash forms
|
|
48
|
+
# are NOT relaxed: command text gives no trustworthy path. Absolute paths are
|
|
49
|
+
# trimmed against the repo root; a `..` segment or a path outside the root blocks.
|
|
50
|
+
# Symlinks: same accepted limitation as above. Editing a generated mirror
|
|
51
|
+
# (`.claude/agents/...`) is overwritten by the next render; the contract tells the
|
|
52
|
+
# implementer to edit the source and run `navori render --apply` (contract, not
|
|
53
|
+
# barrier). Pi keeps the same list in `engines/pi/role-tools.ts`.
|
|
54
|
+
#
|
|
40
55
|
# The managed block below is regenerated by `navori render` and must NOT be
|
|
41
56
|
# edited by hand.
|
|
42
57
|
# Blocking classification (#1117):
|
|
43
|
-
# - implementer writing a Markdown file: hard — Markdown ownership; the message names the route (markdownRequests).
|
|
58
|
+
# - implementer writing a Markdown file outside the AI-doctrine prefixes: hard — Markdown ownership; the message names the allowed prefixes and the route (markdownRequests).
|
|
44
59
|
set -euo pipefail
|
|
45
60
|
|
|
46
61
|
# Command/field extraction (payload → $cmd, $payload). Shared body, single
|
|
@@ -96,12 +111,28 @@ fi
|
|
|
96
111
|
|
|
97
112
|
navori_tool=$(nv_tool)
|
|
98
113
|
|
|
114
|
+
# Closed list of AI-doctrine prefixes (R49), relative to the repo root.
|
|
115
|
+
navori_doctrine_prefixes=(
|
|
116
|
+
packages/core/core-assets/agents/
|
|
117
|
+
packages/core/core-assets/skills/
|
|
118
|
+
packages/core/core-assets/managed/
|
|
119
|
+
packages/core/core-assets/hooks/
|
|
120
|
+
.claude/agents/
|
|
121
|
+
.claude/skills/
|
|
122
|
+
.claude/context/
|
|
123
|
+
.claude/hooks/
|
|
124
|
+
.codex/agents/
|
|
125
|
+
.codex/hooks/
|
|
126
|
+
.codex/orchestrator.md
|
|
127
|
+
.agents/skills/
|
|
128
|
+
.pi/navori/
|
|
129
|
+
)
|
|
130
|
+
|
|
99
131
|
block() {
|
|
100
132
|
echo "[navori] BLOCKED by implementer-no-markdown: $1" >&2
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
echo "[navori] the implementer does not write Markdown (spec 0030, R1). Put the change in \"markdownRequests\", inside .claude/progress/impl_<feature>.json (or .codex/progress/impl_<feature>.json under Codex) — the scribe applies it in this worktree." >&2
|
|
133
|
+
# `placeHook` ships this byte-identical to Claude and Codex (#389), so the
|
|
134
|
+
# message names the engine-neutral handoff home, never one engine's folder.
|
|
135
|
+
echo "[navori] the implementer writes Markdown only for AI doctrine (spec 0052, R49), with the edit tools, under: ${navori_doctrine_prefixes[*]}. Anything else (progress/, specs/, docs/, README, handoffs, paths with '..') goes in \"markdownRequests\", inside .navori/state/handoffs/impl_<feature>.json — the scribe applies it in this worktree. Bash never writes Markdown." >&2
|
|
105
136
|
navori_audit_verdict="block"
|
|
106
137
|
navori_audit_reason="$1"
|
|
107
138
|
exit 2
|
|
@@ -116,6 +147,36 @@ is_md_path() {
|
|
|
116
147
|
esac
|
|
117
148
|
}
|
|
118
149
|
|
|
150
|
+
# True when $1 (a `.md`/`.mdx` target of an EDIT tool) is AI doctrine: relative to
|
|
151
|
+
# the repo root, or absolute and inside it; never with a `..` segment.
|
|
152
|
+
is_doctrine_path() {
|
|
153
|
+
local p=$1 rel root candidate prefix
|
|
154
|
+
case "$p" in
|
|
155
|
+
.. | ../* | */.. | */../*) return 1 ;;
|
|
156
|
+
esac
|
|
157
|
+
case "$p" in
|
|
158
|
+
/*)
|
|
159
|
+
rel=""
|
|
160
|
+
for candidate in "${nv_project_dir:-}" "$(git -C "$(payload_field cwd)" rev-parse --show-toplevel 2>/dev/null || true)"; do
|
|
161
|
+
root=${candidate%/}
|
|
162
|
+
[ -n "$root" ] || continue
|
|
163
|
+
case "$p" in
|
|
164
|
+
"$root"/*) rel=${p#"$root"/}; break ;;
|
|
165
|
+
esac
|
|
166
|
+
done
|
|
167
|
+
[ -n "$rel" ] || return 1
|
|
168
|
+
;;
|
|
169
|
+
*) rel=${p#./} ;;
|
|
170
|
+
esac
|
|
171
|
+
for prefix in "${navori_doctrine_prefixes[@]}"; do
|
|
172
|
+
case "$prefix" in
|
|
173
|
+
*/) case "$rel" in "$prefix"?*) return 0 ;; esac ;;
|
|
174
|
+
*) [ "$rel" = "$prefix" ] && return 0 ;;
|
|
175
|
+
esac
|
|
176
|
+
done
|
|
177
|
+
return 1
|
|
178
|
+
}
|
|
179
|
+
|
|
119
180
|
case "$navori_tool" in
|
|
120
181
|
Write | Edit)
|
|
121
182
|
# Codex's `apply_patch` can touch several files in ONE call (Add/Update/
|
|
@@ -123,7 +184,7 @@ case "$navori_tool" in
|
|
|
123
184
|
# line, so every one of them is checked, not just the first.
|
|
124
185
|
while IFS= read -r navori_path; do
|
|
125
186
|
[ -n "$navori_path" ] || continue
|
|
126
|
-
if is_md_path "$navori_path"; then
|
|
187
|
+
if is_md_path "$navori_path" && ! is_doctrine_path "$navori_path"; then
|
|
127
188
|
block "$navori_tool sobre '$navori_path'"
|
|
128
189
|
fi
|
|
129
190
|
done <<EOF
|
|
@@ -135,7 +196,7 @@ EOF
|
|
|
135
196
|
;;
|
|
136
197
|
NotebookEdit)
|
|
137
198
|
navori_path=$(payload_field tool_input.notebook_path)
|
|
138
|
-
if is_md_path "$navori_path"; then
|
|
199
|
+
if is_md_path "$navori_path" && ! is_doctrine_path "$navori_path"; then
|
|
139
200
|
block "NotebookEdit sobre '$navori_path'"
|
|
140
201
|
fi
|
|
141
202
|
navori_audit_verdict="allow"
|
|
@@ -14,7 +14,7 @@ set -euo pipefail
|
|
|
14
14
|
|
|
15
15
|
# Every matching command needs this literal token, so its absence from the raw
|
|
16
16
|
# payload proves this hook cannot apply and avoids parsing the payload at all.
|
|
17
|
-
TRIGGER_TOKENS='approved-by delivery-baseline delivery-queue navori master'
|
|
17
|
+
TRIGGER_TOKENS='approved-by delivery-baseline delivery-queue escalation-reset navori master'
|
|
18
18
|
# navori:include gate-trigger
|
|
19
19
|
|
|
20
20
|
has_trigger_token "${payload:-}" || exit 0
|
|
@@ -56,12 +56,20 @@ QUEUE_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-queue([[:space:]]|\$).
|
|
|
56
56
|
DELIVERY_REVIEW_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-(review|decision|publication|revoke)([[:space:];&|)<>]|\$)"
|
|
57
57
|
DELIVERY_CRITERION_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-criterion([[:space:]]|\$).*--approved-by([[:space:]=]|\$)"
|
|
58
58
|
DELIVERY_REFRESH_RE="${NAVORI}[[:space:]]+master[[:space:]]+delivery-slice([[:space:]]|\$).*--refresh([[:space:]=]|\$)"
|
|
59
|
-
|
|
59
|
+
RESET_RE="${NAVORI}[[:space:]]+plan[[:space:]]+escalation-reset([[:space:]]|\$).*--approved-by([[:space:]=]|\$)"
|
|
60
|
+
TRIGGER_RE=".*${BOUND}(${PART_RE}|${CLOSE_RE}|${BASELINE_RE}|${QUEUE_RE}|${DELIVERY_REVIEW_RE}|${DELIVERY_CRITERION_RE}|${DELIVERY_REFRESH_RE}|${RESET_RE})"
|
|
60
61
|
# A command substitution starts a new command: make it a segment of its own so
|
|
61
62
|
# the `VAR=$(` prefix peeling in the shared scan cannot swallow it.
|
|
62
63
|
navori_subst='$('
|
|
63
64
|
navori_semi='; '
|
|
64
65
|
cmd="${cmd//"$navori_subst"/$navori_semi}"
|
|
66
|
+
# Quotes hide the boundary from `BOUND` in wrapped forms (`sh -c 'navori master
|
|
67
|
+
# close'`, `eval "…"`, `navori master "close"`): delete ONLY `"` and `'`. Never
|
|
68
|
+
# `\`: `navori_count_triggers` joins a `\<newline>` continuation first, and
|
|
69
|
+
# removing the backslash here would split the segment (spec 0052 R36). The cost
|
|
70
|
+
# is asking on `echo "navori master close"`, the safe direction. A word split by
|
|
71
|
+
# quotes (`navo"ri`) is deliberate obfuscation this hook does not claim to catch.
|
|
72
|
+
cmd="${cmd//[\"\']/}"
|
|
65
73
|
is_scan_trigger "$cmd" || exit 0
|
|
66
74
|
|
|
67
75
|
approval_description="a manual criterion"
|
|
@@ -71,6 +79,7 @@ case "$cmd" in
|
|
|
71
79
|
*delivery-publication*) approval_description="the release or deploy reference for this exact accepted delivery; this records publication, it does not deploy" ;;
|
|
72
80
|
*delivery-revoke*) approval_description="revocation of the active authority generation and prior effective proof" ;;
|
|
73
81
|
*delivery-slice*) approval_description="the current-queue reverification refresh; pending criteria reset and normal plan approval is required again" ;;
|
|
82
|
+
*escalation-reset*) approval_description="another review round after two rejections at level 2 or higher" ;;
|
|
74
83
|
*delivery-baseline*|*delivery-queue*) approval_description="the delivery baseline or bounded queue" ;;
|
|
75
84
|
esac
|
|
76
85
|
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Startup/lifecycle modes advise the main session; `/model` remains the user's
|
|
4
|
+
# explicit switch path. The fixed Codex PreToolUse mode rewrites future child
|
|
5
|
+
# arguments or denies recoverable failures. No mode writes project config or profiles.
|
|
6
6
|
set -euo pipefail
|
|
7
7
|
|
|
8
8
|
payload=$(cat 2>/dev/null) || payload=""
|
|
9
9
|
mode=${1:-}
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# points share one script, so derive the host phase from the explicit mode
|
|
14
|
-
# rather than guessing from payload fields.
|
|
11
|
+
# Lifecycle advice has no permission effect; the fixed native PreToolUse mode
|
|
12
|
+
# does affect tool control flow. Audit both through their explicit host phase.
|
|
15
13
|
case "$mode" in
|
|
16
14
|
claude-session-start|codex-session-start) navori_audit_phase="SessionStart" ;;
|
|
17
15
|
claude-post-model-switch) navori_audit_phase="PostModelSwitch" ;;
|
|
18
16
|
claude-stop) navori_audit_phase="Stop" ;;
|
|
17
|
+
codex-pretool-profile) navori_audit_phase="PreToolUse" ;;
|
|
19
18
|
*) navori_audit_phase="unknown" ;;
|
|
20
19
|
esac
|
|
21
20
|
navori_audit_name="model-advisor"
|
|
@@ -24,12 +23,100 @@ navori_audit_log() { :; }
|
|
|
24
23
|
# navori:include audit-repo
|
|
25
24
|
# navori:include audit-log
|
|
26
25
|
navori_audit_begin
|
|
26
|
+
navori_profile_verdict="skip"
|
|
27
27
|
navori_audit_on_exit() {
|
|
28
|
-
navori_audit_log "
|
|
28
|
+
navori_audit_log "$navori_profile_verdict" || true
|
|
29
29
|
return 0
|
|
30
30
|
}
|
|
31
31
|
trap navori_audit_on_exit EXIT
|
|
32
32
|
|
|
33
|
+
# Fixed native argument rewrite, before the advisory subagent skip. Recoverable
|
|
34
|
+
# failures deny; an outer host timeout/crash can fail open and remains uncertain.
|
|
35
|
+
if [ "$mode" = "codex-pretool-profile" ]; then
|
|
36
|
+
if [ "${#payload}" -gt 1048576 ]; then
|
|
37
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"profile-hook-input-too-large; effective-selection-pending"}}'
|
|
38
|
+
navori_profile_verdict="block"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
profile_exit=0
|
|
42
|
+
profile_output=$(node 3<<<"$payload" <<'PROFILE_NODE'
|
|
43
|
+
const { spawn } = require("node:child_process");
|
|
44
|
+
const rawPayload = require("node:fs").readFileSync(3, "utf8");
|
|
45
|
+
const deny = { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "profile-hook-cli-unavailable-or-invalid; effective-selection-pending" } };
|
|
46
|
+
let child;
|
|
47
|
+
let timer;
|
|
48
|
+
let finished = false;
|
|
49
|
+
const finish = (value, code) => {
|
|
50
|
+
if (finished) return;
|
|
51
|
+
finished = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (child?.pid) {
|
|
54
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write(JSON.stringify(value) + "\n");
|
|
57
|
+
process.exitCode = code;
|
|
58
|
+
};
|
|
59
|
+
let original;
|
|
60
|
+
try { original = JSON.parse(rawPayload); }
|
|
61
|
+
catch { finish(deny, 2); }
|
|
62
|
+
if (!finished) {
|
|
63
|
+
const bytes = Buffer.byteLength(rawPayload);
|
|
64
|
+
if (bytes > 1024 * 1024) finish(deny, 2);
|
|
65
|
+
else {
|
|
66
|
+
child = spawn("navori", ["codex", "profile", "hook"], { detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
timer = setTimeout(() => finish(deny, 2), 11_000);
|
|
70
|
+
child.once("error", () => finish(deny, 2));
|
|
71
|
+
child.stdin.on("error", () => finish(deny, 2));
|
|
72
|
+
child.stdout.on("data", chunk => {
|
|
73
|
+
size += chunk.length;
|
|
74
|
+
if (size > 1024 * 1024) finish(deny, 2);
|
|
75
|
+
else chunks.push(chunk);
|
|
76
|
+
});
|
|
77
|
+
// Audit belongs on stderr; do not forward untrusted CLI diagnostics/payloads.
|
|
78
|
+
child.stderr.on("data", () => {});
|
|
79
|
+
child.once("close", (code, signal) => {
|
|
80
|
+
if (finished) return;
|
|
81
|
+
if (code !== 0 || signal) return finish(deny, 2);
|
|
82
|
+
try {
|
|
83
|
+
const result = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
84
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result) && Object.getPrototypeOf(result) === Object.prototype && Object.keys(result).length === 0) return finish({}, 3);
|
|
85
|
+
const output = result?.hookSpecificOutput;
|
|
86
|
+
if (output?.hookEventName !== "PreToolUse") return finish(deny, 2);
|
|
87
|
+
if (output.permissionDecision === "deny" && typeof output.permissionDecisionReason === "string") return finish(result, 2);
|
|
88
|
+
const updated = output.updatedInput;
|
|
89
|
+
const input = original?.tool_input;
|
|
90
|
+
if (output.permissionDecision !== "allow" || !updated || !input || Array.isArray(updated) || Array.isArray(input) || typeof updated !== "object" || typeof input !== "object" || typeof updated.model !== "string" || !updated.model || typeof updated.reasoning_effort !== "string" || !updated.reasoning_effort) return finish(deny, 2);
|
|
91
|
+
const keys = new Set([...Object.keys(input), ...Object.keys(updated)]);
|
|
92
|
+
for (const key of keys) {
|
|
93
|
+
if (key === "model" || key === "reasoning_effort") continue;
|
|
94
|
+
if (JSON.stringify(input[key]) !== JSON.stringify(updated[key])) return finish(deny, 2);
|
|
95
|
+
}
|
|
96
|
+
finish(result, 0);
|
|
97
|
+
} catch { finish(deny, 2); }
|
|
98
|
+
});
|
|
99
|
+
child.stdin.end(rawPayload);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
PROFILE_NODE
|
|
103
|
+
) || profile_exit=$?
|
|
104
|
+
# Capture first: a missing/fatal/signaled supervisor cannot leak a partial
|
|
105
|
+
# response or append a second JSON document after an already valid denial.
|
|
106
|
+
if [ -n "$profile_output" ] && { [ "$profile_exit" -eq 0 ] || [ "$profile_exit" -eq 2 ] || [ "$profile_exit" -eq 3 ]; }; then
|
|
107
|
+
printf '%s\n' "$profile_output"
|
|
108
|
+
else
|
|
109
|
+
printf '%s\n' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"profile-hook-supervisor-unavailable; effective-selection-pending"}}'
|
|
110
|
+
profile_exit=2
|
|
111
|
+
fi
|
|
112
|
+
case "$profile_exit" in
|
|
113
|
+
0) navori_profile_verdict="allow" ;;
|
|
114
|
+
3) navori_profile_verdict="skip" ;;
|
|
115
|
+
*) navori_profile_verdict="block" ;;
|
|
116
|
+
esac
|
|
117
|
+
exit 0
|
|
118
|
+
fi
|
|
119
|
+
|
|
33
120
|
# `Stop` fires for the main agent, but a session started with `--agent` carries
|
|
34
121
|
# `agent_type`, and any payload with `agent_id` belongs to a subagent: neither
|
|
35
122
|
# can advise, so their `node` spawn is pure waste. Discard it here with a
|