@azure-id/orc 1.4.2 → 1.6.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 +186 -0
- package/README.md +63 -50
- package/bin/cli.js +5732 -133
- package/bin/mockrun-catalog.js +4 -0
- package/bin/verify-contracts.js +4509 -4426
- package/bin/verify-package.js +24 -2
- package/bin/webui/api.js +108 -8
- package/bin/webui/app.html +9 -0
- package/bin/webui/css/06-responsive.css +14 -0
- package/bin/webui/css/panels/test.css +132 -0
- package/bin/webui/fixtures/index.js +23 -1
- package/bin/webui/fixtures/test.js +547 -0
- package/bin/webui/i18n/en/common.json +1 -0
- package/bin/webui/i18n/en/nav.json +1 -0
- package/bin/webui/i18n/en/overview.json +5 -0
- package/bin/webui/i18n/en/test.json +141 -0
- package/bin/webui/i18n/id/common.json +1 -0
- package/bin/webui/i18n/id/nav.json +1 -0
- package/bin/webui/i18n/id/overview.json +5 -0
- package/bin/webui/i18n/id/test.json +141 -0
- package/bin/webui/js/01-i18n.js +1 -0
- package/bin/webui/js/panels/challenge.js +8 -3
- package/bin/webui/js/panels/overview.js +18 -0
- package/bin/webui/js/panels/test.js +733 -0
- package/bin/webui/serve.js +572 -557
- package/mock-run/orc-test.md +327 -0
- package/package.json +1 -1
- package/templates/agents/MODEL-MAPPING.md +2 -0
- package/templates/agents/orc-test-designer-opus-5-high.md +158 -0
- package/templates/agents/orc-test-interpreter-opus-5-low.md +130 -0
- package/templates/commands/orc-test.md +66 -0
- package/templates/hooks/README.md +110 -1
- package/templates/hooks/orc-read-gate.js +279 -0
- package/templates/skills/_shared/extra-dispatch.md +1346 -1337
- package/templates/skills/_shared/live-target.md +188 -0
- package/templates/skills/_shared/phases/trace.md +2 -2
- package/templates/skills/_shared/wait.md +241 -240
- package/templates/skills/orc-test/SKILL.md +233 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run the tests against the real running system — happy path to security — and report only what was observed
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Use the **orc-test** skill. Standalone — no plan, no build, no code written into
|
|
6
|
+
your project.
|
|
7
|
+
|
|
8
|
+
Every other ORC lane reasons about your system. This one **runs it**:
|
|
9
|
+
|
|
10
|
+
> **A test that ran is a FACT; a test that was written is an OPINION.**
|
|
11
|
+
|
|
12
|
+
ORC already has a lane that WRITES tests and never runs them (Phase 6.5,
|
|
13
|
+
`test-generator/`). This is the mirror image, and the two never mix: `/orc-test`
|
|
14
|
+
writes nothing into your test tree and runs everything.
|
|
15
|
+
|
|
16
|
+
**It never edits the system it is testing.** If the server will not come up, it
|
|
17
|
+
prints exactly what it ran and the tail of the output, and hands back. You fix
|
|
18
|
+
it, and re-run for free — the same shape as `/orc-challenge`, and for the same
|
|
19
|
+
reason: a session that just fixed the code cannot be trusted to measure it.
|
|
20
|
+
|
|
21
|
+
**It never reports a result it did not observe.** Three verdicts and no fourth:
|
|
22
|
+
`pass` (observed, matched), `fail` (observed, did not match), and `unknown` —
|
|
23
|
+
not observed. `unknown` keeps its slot in the report and never becomes a pass.
|
|
24
|
+
A flake is recorded, never retried away: there is no retry count, because if the
|
|
25
|
+
same case answers differently twice, *that instability is the finding*.
|
|
26
|
+
|
|
27
|
+
One pass:
|
|
28
|
+
|
|
29
|
+
1. **Target** — `orc test init <slug>` asks what it must not guess: back end or
|
|
30
|
+
front end, local or remote, the base URL. On a remote target it also requires
|
|
31
|
+
`--authorized "<who authorized this, and where it is recorded>"`, stored
|
|
32
|
+
verbatim and reprinted at the head of every report. ORC cannot verify
|
|
33
|
+
authorization and does not pretend to — it makes the assertion impossible to
|
|
34
|
+
skip. Frozen, and never asked again.
|
|
35
|
+
2. **Surface** — free, deterministic: an OpenAPI or GraphQL spec on disk, the
|
|
36
|
+
same spec at the target, route extraction by framework fingerprint, FE router
|
|
37
|
+
config. Anything ambiguous comes back as `unresolved[]` and is never guessed.
|
|
38
|
+
The **code-vs-live diff** is a first-class output: routes that answer live and
|
|
39
|
+
exist in no source file are zombie APIs.
|
|
40
|
+
3. **Flow** — it digs the repo for how a request reaches your endpoint: the
|
|
41
|
+
route registration, the middleware in order, the handler, what it writes, what
|
|
42
|
+
must be true first, which fields name an object, and the login flow itself.
|
|
43
|
+
4. **Environment** (local only) — it brings the system up and waits for health.
|
|
44
|
+
Five states, one next action each.
|
|
45
|
+
5. **Cases** — the CLI derives the bulk for free: equivalence partitions,
|
|
46
|
+
boundary values, method and content-type negatives, stateful create → read →
|
|
47
|
+
update → delete sequences. An agent fills only what a schema cannot know.
|
|
48
|
+
6. **Run** — paced, capped, and fenced to the frozen origin. A 429 is a RESULT,
|
|
49
|
+
not an error: it means rate limiting works. Every case leaves its exact
|
|
50
|
+
request, its exact response and a reproducible `curl`, with every credential
|
|
51
|
+
redacted **before the bytes reach disk**.
|
|
52
|
+
7. **Report** — `orc/orc-test/<slug>/REPORT.md`, written for someone who does
|
|
53
|
+
not read code.
|
|
54
|
+
|
|
55
|
+
The security tier is a **closed set** mapped to the OWASP API Security Top 10
|
|
56
|
+
(2023), and it demonstrates a CONDITION rather than an extraction — confirming
|
|
57
|
+
an exploit is your decision on your authority. With one identity, BOLA and BFLA
|
|
58
|
+
report `UNCHECKABLE`, which keeps its slot and never becomes a pass.
|
|
59
|
+
|
|
60
|
+
**The run folder is never staged.** Evidence contains real response bodies from
|
|
61
|
+
a real system. The lane offers you the one `.gitignore` line; it does not edit
|
|
62
|
+
`.gitignore` itself.
|
|
63
|
+
|
|
64
|
+
Read the state back any time without this lane: `orc test status <slug>`.
|
|
65
|
+
|
|
66
|
+
The slug to open or reopen (or nothing, and it will ask): $ARGUMENTS
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
# The ORC
|
|
1
|
+
# The ORC hooks
|
|
2
2
|
|
|
3
3
|
> This page is written in Simplified Technical English. Short sentences, one
|
|
4
4
|
> idea each, plain words. See `bin/webui/i18n/TERMS.md` for the term list.
|
|
5
5
|
|
|
6
|
+
This page covers the two hooks you can see. The STATUS LINE, which shows what
|
|
7
|
+
is happening, and the READ GATE, which can stop an oversized read.
|
|
8
|
+
|
|
6
9
|
ORC shows two lines at the bottom of your terminal. Claude Code draws them.
|
|
7
10
|
ORC writes them.
|
|
8
11
|
|
|
@@ -258,6 +261,91 @@ it did not see.
|
|
|
258
261
|
|
|
259
262
|
---
|
|
260
263
|
|
|
264
|
+
## The read gate
|
|
265
|
+
|
|
266
|
+
This is a different hook. It is off when you install ORC.
|
|
267
|
+
|
|
268
|
+
ORC has a rule about reading. The main session reads a file to FIND things.
|
|
269
|
+
To UNDERSTAND a file, ORC sends an agent to read it and report back. The agent
|
|
270
|
+
uses its own context, not yours. The rule was written in three guide files and
|
|
271
|
+
nothing checked it. The read gate is the part that can say no.
|
|
272
|
+
|
|
273
|
+
Turn it on like this:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
orc config set read_gate warn # it tells you, and still reads the file
|
|
277
|
+
orc config set read_gate block # it stops the read
|
|
278
|
+
orc config set read_gate off # the default
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### What it does
|
|
282
|
+
|
|
283
|
+
It looks at one thing: a `Read` of a whole file, by the main session, during an
|
|
284
|
+
ORC run, when the file is 1000 lines or more.
|
|
285
|
+
|
|
286
|
+
In `warn` it shows you a note and reads the file anyway. In `block` it stops the
|
|
287
|
+
read and tells you three other ways to get what you need:
|
|
288
|
+
|
|
289
|
+
- Read the file with `offset` and `limit`. This is never stopped.
|
|
290
|
+
- Search the file first, then read that part.
|
|
291
|
+
- Send an agent to read it. An agent's reads are never stopped.
|
|
292
|
+
|
|
293
|
+
**A block always names another way.** A gate that only says no is a gate people
|
|
294
|
+
turn off.
|
|
295
|
+
|
|
296
|
+
### When it says nothing
|
|
297
|
+
|
|
298
|
+
This list is the important part. The gate is quiet in all of these states, and
|
|
299
|
+
each one is on purpose:
|
|
300
|
+
|
|
301
|
+
| State | Why |
|
|
302
|
+
|---|---|
|
|
303
|
+
| An agent is reading | An agent must read a file in full before it edits it. If it could not, it would guess the old text and damage the file. |
|
|
304
|
+
| `read_gate` is `off` | This is the default. With `off`, the hook does nothing at all. |
|
|
305
|
+
| No ORC run is open | The gate is about ORC's own reading. It is not a rule for your session. Outside a run it never stops anything. |
|
|
306
|
+
| You used `offset` or `limit` | This is the behaviour the gate wants. It can never stop it. |
|
|
307
|
+
| The file is under 1000 lines | See the next part. |
|
|
308
|
+
| The file is a build log, a test result, or `.jsonl` | ORC reads these to decide pass or fail. A cut-short failing build looks like a passing build. That is worse than any saving. |
|
|
309
|
+
| The gate hit an error | It always lets the read through. Then it writes down what went wrong. |
|
|
310
|
+
|
|
311
|
+
**The gate cannot see a file you read with a shell command** such as `cat` or
|
|
312
|
+
`head`. It only sees the `Read` tool.
|
|
313
|
+
|
|
314
|
+
### Why 1000 lines
|
|
315
|
+
|
|
316
|
+
Sending an agent is not free. One real agent read cost about 13,000 tokens and
|
|
317
|
+
about 76 seconds. A line in this project is about 55 characters. So a file must
|
|
318
|
+
be near 1000 lines before sending an agent costs less than reading it yourself.
|
|
319
|
+
|
|
320
|
+
Below that number, sending an agent costs more than it saves.
|
|
321
|
+
|
|
322
|
+
You can change it:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
orc config set read_gate_max_lines 500
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### What it never does
|
|
329
|
+
|
|
330
|
+
- It never stops an agent's read.
|
|
331
|
+
- It never stops a read outside an ORC run.
|
|
332
|
+
- It never stops a `Bash` command.
|
|
333
|
+
- It never reads the file to you. It only counts the lines.
|
|
334
|
+
- It never fails closed. If the hook breaks, your read still happens.
|
|
335
|
+
|
|
336
|
+
### Where to look when it acts
|
|
337
|
+
|
|
338
|
+
Every `warn` and every `block` writes one line in the run trace:
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
[070926 14:22:01.220] hook READ-GATE block :: lines=2400 max=1000 file=big-plan.md
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
An allowed read writes nothing. `orc doctor` tells you if the gate is on but not
|
|
345
|
+
wired, and if it ever had to let a read through because it could not judge it.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
261
349
|
## For maintainers
|
|
262
350
|
|
|
263
351
|
- The hook is `orc-statusline.js`. `orc init` installs it and wires it into
|
|
@@ -285,3 +373,24 @@ it did not see.
|
|
|
285
373
|
- The cache file is `.claude/orc/usage-session.json`. The hook reads it once and
|
|
286
374
|
writes it once, after the text is ready. It stores raw numbers only — never a
|
|
287
375
|
word like `fresh` or `STALE`, which is computed each time it is shown.
|
|
376
|
+
|
|
377
|
+
### The read gate
|
|
378
|
+
|
|
379
|
+
- The hook is `orc-read-gate.js`, on `PreToolUse` with matcher `Read`. `orc
|
|
380
|
+
init` wires it even though `read_gate` defaults to `off`, so arming it is a
|
|
381
|
+
config edit and never an install step somebody has to find.
|
|
382
|
+
- **`off` is byte-identical to not having the hook**, and a test asserts it.
|
|
383
|
+
- **`agent_id` is the only way to tell a subagent's read from the main
|
|
384
|
+
session's, and this was MEASURED, not assumed.** `PreToolUse` does fire
|
|
385
|
+
inside a dispatched subagent, and `session_id` and `transcript_path` are
|
|
386
|
+
identical in both. A gate written against either would block the full read an
|
|
387
|
+
executor must do before it edits, and a reconstructed `old_string` corrupts
|
|
388
|
+
files. Test for the PRESENCE of `agent_id`. Never test for the absence of
|
|
389
|
+
another key — that is not a positive statement about anything.
|
|
390
|
+
- The threshold is measured, not borrowed. See `read_gate_max_lines`.
|
|
391
|
+
- It fails open on every path, and each failure it can name writes
|
|
392
|
+
`.claude/orc/read-gate-fallback.json` for `orc doctor` to turn into a
|
|
393
|
+
sentence — only while the feature is armed.
|
|
394
|
+
- It writes one trace line per `warn` and per `block`, never on an allow. That
|
|
395
|
+
is affordable here for a structural reason: the gate only acts while a run is
|
|
396
|
+
open, so a trace always exists.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ORC read gate — a Claude Code PreToolUse hook on `Read`.
|
|
6
|
+
*
|
|
7
|
+
* ORC's read discipline (`skills/_shared/read-ladder.md`, orc-doc hard rule 0,
|
|
8
|
+
* orc-quick line 21) says the ORCHESTRATOR reads to LOCATE and dispatches to
|
|
9
|
+
* UNDERSTAND. That rule was advisory prose in three places and enforced in
|
|
10
|
+
* none. This hook is the one layer that can refuse.
|
|
11
|
+
*
|
|
12
|
+
* It is DELIBERATELY the narrowest useful gate. Everything below is a state in
|
|
13
|
+
* which it says nothing at all, and every one of them is intentional:
|
|
14
|
+
*
|
|
15
|
+
* - a SUBAGENT is reading (measured: W1 — see `agent_id` below)
|
|
16
|
+
* - `read_gate` is `off` (the default)
|
|
17
|
+
* - no ORC run is open
|
|
18
|
+
* - the read is already targeted (`offset` / `limit` — ladder step 3)
|
|
19
|
+
* - the file is under the threshold
|
|
20
|
+
* - the file is output a gate parses (ladder exception 2)
|
|
21
|
+
* - `read_gate` is `warn` (it says so, and still allows)
|
|
22
|
+
*
|
|
23
|
+
* THE SUBAGENT RULE IS LOAD-BEARING, AND IT IS MEASURED, NOT ASSUMED.
|
|
24
|
+
* `PreToolUse` DOES fire inside a dispatched subagent. `session_id` and
|
|
25
|
+
* `transcript_path` are IDENTICAL in both contexts — a gate written against
|
|
26
|
+
* either would treat an executor's read as the orchestrator's and block the
|
|
27
|
+
* full read that must precede an `Edit`, whose `old_string` cannot be
|
|
28
|
+
* reconstructed from an outline. That is a file-corruption path. The ONLY
|
|
29
|
+
* discriminator is `agent_id`, present in a subagent payload and absent in a
|
|
30
|
+
* main-session one. Test for its PRESENCE — never for the absence of some
|
|
31
|
+
* other key, which is not a positive assertion.
|
|
32
|
+
*
|
|
33
|
+
* Wiring (installed by `orc init` into .claude/settings.json):
|
|
34
|
+
* hooks.PreToolUse[] { matcher: "Read", hooks:[{ type:"command",
|
|
35
|
+
* command: 'node "<.claude>/hooks/orc-read-gate.js"' }] }
|
|
36
|
+
*
|
|
37
|
+
* Contract: read PreToolUse JSON from stdin. Exit 0 = allow. Exit 2 = block
|
|
38
|
+
* the tool call and show stderr to Claude, which relays it to the user.
|
|
39
|
+
*
|
|
40
|
+
* FAIL-OPEN, ALWAYS. A read gate that throws and blocks a read has broken the
|
|
41
|
+
* tool. Every failure path here exits 0, and the ones we can name RECORD
|
|
42
|
+
* themselves so `orc doctor` can turn them into a sentence — but only while
|
|
43
|
+
* the feature is armed. Precedent: orc-statusline.js is fail-silent, and the
|
|
44
|
+
* v1.3.0 statusline gate ladder is all fallbacks, each of which records itself.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
const fs = require("fs");
|
|
48
|
+
const path = require("path");
|
|
49
|
+
|
|
50
|
+
// .claude/hooks/orc-read-gate.js → CLAUDE_DIR = .. → PROJECT_ROOT = ../..
|
|
51
|
+
const CLAUDE_DIR = path.join(__dirname, "..");
|
|
52
|
+
const PROJECT_ROOT = path.join(CLAUDE_DIR, "..");
|
|
53
|
+
|
|
54
|
+
// A run whose trace has been idle this long has ENDED. Same window as
|
|
55
|
+
// orc-trace.js — one idea of "a run is open", not two.
|
|
56
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
57
|
+
|
|
58
|
+
// Default line threshold. MEASURED, not copied.
|
|
59
|
+
//
|
|
60
|
+
// The number in the source material this pattern came from is 350, derived
|
|
61
|
+
// from a 10-30s delegation round trip. ORC's is nothing like that: a Task
|
|
62
|
+
// dispatch measured p50 76s / p90 188s (n=125), and one real read-only
|
|
63
|
+
// dispatch cost 13,276 tokens to read a four-line file and return three words.
|
|
64
|
+
// At the measured 55.2 chars/line across 316 main-session full reads, that
|
|
65
|
+
// floor is ~962 lines; at the median file's 51.9 chars/line it is ~1024.
|
|
66
|
+
// Break-even is therefore ~1000 lines, and BELOW it delegating a read costs
|
|
67
|
+
// more than the read does. Copying 350 would delegate work whose overhead
|
|
68
|
+
// exceeds its saving.
|
|
69
|
+
const DEFAULT_THRESHOLD = 1000;
|
|
70
|
+
|
|
71
|
+
// Output a gate PARSES is read whole — read-ladder exception 2. The smoke
|
|
72
|
+
// gate, the TDD gate, the verifier and /orc-quick's build loop all decide red
|
|
73
|
+
// vs green from these exact bytes, and a truncated red build reads GREEN.
|
|
74
|
+
// That is worse than any token saving, so this list is deliberately generous:
|
|
75
|
+
// a false ALLOW costs tokens, a false BLOCK costs correctness.
|
|
76
|
+
const GATE_PARSED =
|
|
77
|
+
/(\.(log|tap|xml|lcov|junit)$|(^|[\\/])(coverage|test-results|npm-debug|yarn-error)([\\/]|$)|\.jsonl$)/i;
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Config — tolerant top-level YAML scalar read, matching orc-trace.js exactly.
|
|
81
|
+
// The repo is zero-dep; there is no YAML parser.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
function readConfigScalar(key) {
|
|
84
|
+
try {
|
|
85
|
+
const text = fs.readFileSync(path.join(CLAUDE_DIR, "orc.config.yaml"), "utf8");
|
|
86
|
+
const re = new RegExp("^" + key + "\\s*:\\s*(.+?)\\s*(?:#.*)?$", "m");
|
|
87
|
+
const m = text.match(re);
|
|
88
|
+
return m ? m[1].replace(/^['"]|['"]$/g, "").trim() : null;
|
|
89
|
+
} catch (_) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function logDir() {
|
|
95
|
+
const rel = readConfigScalar("log_dir") || ".claude/orc/logs";
|
|
96
|
+
return path.isAbsolute(rel) ? rel : path.join(PROJECT_ROOT, rel);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Is an ORC run open? `.current` names the run; the trace file it names must
|
|
100
|
+
// exist and be fresh. A pointer naming a file that does not exist is
|
|
101
|
+
// indistinguishable from a dangling one (v0.34.2), so honour a pointer whose
|
|
102
|
+
// OWN mtime is fresh too — neither half depends on the other.
|
|
103
|
+
function runIsOpen() {
|
|
104
|
+
const dir = logDir();
|
|
105
|
+
let name;
|
|
106
|
+
try {
|
|
107
|
+
name = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
|
|
108
|
+
} catch (_) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (!name) return false;
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
try {
|
|
114
|
+
if (now - fs.statSync(path.join(dir, name)).mtimeMs < STALE_MS) return true;
|
|
115
|
+
} catch (_) {
|
|
116
|
+
/* trace file not created yet — fall through to the pointer's own mtime */
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return now - fs.statSync(path.join(dir, ".current")).mtimeMs < STALE_MS;
|
|
120
|
+
} catch (_) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// D10 — A GATE DECISION THAT LEAVES NO LINE CANNOT BE COUNTED. Same rule as a
|
|
126
|
+
// resume and a demotion, and it is CHEAP HERE FOR A STRUCTURAL REASON: the gate
|
|
127
|
+
// only ever acts while a run is open (rung 2), so a trace to write into is
|
|
128
|
+
// guaranteed to exist by the time we get here. There is no "a read happened
|
|
129
|
+
// with no run" case to handle, because that read was already allowed.
|
|
130
|
+
//
|
|
131
|
+
// Hook-composed, like every other line orc-trace.js writes. Best effort: a line
|
|
132
|
+
// that cannot be written must never take the gate down with it.
|
|
133
|
+
function traceLine(verb, tail) {
|
|
134
|
+
try {
|
|
135
|
+
const dir = logDir();
|
|
136
|
+
const name = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
|
|
137
|
+
if (!name) return;
|
|
138
|
+
const d = new Date();
|
|
139
|
+
const p = (n, w) => String(n).padStart(w || 2, "0");
|
|
140
|
+
const stamp =
|
|
141
|
+
p(d.getDate()) + p(d.getMonth() + 1) + p(d.getFullYear() % 100) + " " +
|
|
142
|
+
p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()) +
|
|
143
|
+
"." + p(d.getMilliseconds(), 3);
|
|
144
|
+
fs.appendFileSync(
|
|
145
|
+
path.join(dir, name),
|
|
146
|
+
`[${stamp}] ${"hook".padEnd(8)} ${verb}` + (tail ? ` :: ${tail}` : "") + "\n"
|
|
147
|
+
);
|
|
148
|
+
} catch (_) {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A fallback RECORDS ITSELF (v1.3.0's rule). Best effort by construction: a
|
|
152
|
+
// record that cannot be written must never take the gate down with it.
|
|
153
|
+
function recordFallback(rung, detail) {
|
|
154
|
+
try {
|
|
155
|
+
const dir = path.join(CLAUDE_DIR, "orc");
|
|
156
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
157
|
+
fs.writeFileSync(
|
|
158
|
+
path.join(dir, "read-gate-fallback.json"),
|
|
159
|
+
JSON.stringify({ rung, detail: String(detail || "").slice(0, 300), at: Date.now() })
|
|
160
|
+
);
|
|
161
|
+
} catch (_) {}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Count lines without holding the whole file in memory twice. Returns null when
|
|
165
|
+
// the file cannot be measured — and null NEVER blocks (unknown is not "big").
|
|
166
|
+
function countLines(file) {
|
|
167
|
+
try {
|
|
168
|
+
const st = fs.statSync(file);
|
|
169
|
+
if (!st.isFile()) return null;
|
|
170
|
+
// A file that cannot possibly reach the threshold is not worth reading.
|
|
171
|
+
// 1 byte/line is the floor, so bytes < threshold ⇒ lines < threshold.
|
|
172
|
+
const buf = fs.readFileSync(file);
|
|
173
|
+
let n = 1;
|
|
174
|
+
for (let i = 0; i < buf.length; i++) if (buf[i] === 10) n++;
|
|
175
|
+
return n;
|
|
176
|
+
} catch (_) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let raw = "";
|
|
182
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
183
|
+
process.stdin.on("end", () => {
|
|
184
|
+
// RUNG 0 — anything at all goes wrong: ALLOW. This wrapper is the whole
|
|
185
|
+
// fail-open guarantee; every `return` below it is an allow.
|
|
186
|
+
try {
|
|
187
|
+
gate();
|
|
188
|
+
} catch (e) {
|
|
189
|
+
recordFallback("threw", e && e.message);
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
process.exit(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
function gate() {
|
|
196
|
+
let data;
|
|
197
|
+
try {
|
|
198
|
+
data = JSON.parse(raw || "{}");
|
|
199
|
+
} catch (e) {
|
|
200
|
+
recordFallback("unparseable-payload", e && e.message);
|
|
201
|
+
return; // allow
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (data.tool_name !== "Read") return;
|
|
205
|
+
|
|
206
|
+
// RUNG 0.5 — a SUBAGENT is reading. ALLOW, always, and this check comes
|
|
207
|
+
// before every other one. See the header: this is what keeps the gate off
|
|
208
|
+
// the pre-`Edit` full read that an executor must perform, and it is why the
|
|
209
|
+
// gate never needs to know a slice's declared_files.
|
|
210
|
+
if (data.agent_id != null) return;
|
|
211
|
+
|
|
212
|
+
const mode = String(readConfigScalar("read_gate") || "off").toLowerCase();
|
|
213
|
+
if (mode !== "warn" && mode !== "block") return; // `off` (the default), or garbage
|
|
214
|
+
|
|
215
|
+
// RUNG 2 — no ORC run is open. The gate constrains ORC's own reading; it is
|
|
216
|
+
// not a policy on the user's session. A read outside a run is not ORC's to
|
|
217
|
+
// refuse, and the gate says nothing. This is an HONEST LIMIT, not an
|
|
218
|
+
// oversight: it is stated in templates/hooks/README.md and in `orc doctor`.
|
|
219
|
+
if (!runIsOpen()) return;
|
|
220
|
+
|
|
221
|
+
const input = data.tool_input || {};
|
|
222
|
+
const file = String(input.file_path || "");
|
|
223
|
+
if (!file) return;
|
|
224
|
+
|
|
225
|
+
// RUNG 3 — already a targeted read. This IS ladder step 3; it is the
|
|
226
|
+
// behaviour the gate exists to encourage, so it can never be refused.
|
|
227
|
+
if (input.offset != null || input.limit != null) return;
|
|
228
|
+
|
|
229
|
+
// RUNG 5 — output a gate parses. Exception 2 is not a preference.
|
|
230
|
+
if (GATE_PARSED.test(file)) return;
|
|
231
|
+
|
|
232
|
+
let threshold = parseInt(readConfigScalar("read_gate_max_lines"), 10);
|
|
233
|
+
if (!Number.isFinite(threshold) || threshold < 1) threshold = DEFAULT_THRESHOLD;
|
|
234
|
+
|
|
235
|
+
const lines = countLines(file);
|
|
236
|
+
if (lines == null) {
|
|
237
|
+
// Unmeasurable — a directory, a missing file, a permissions error, a binary
|
|
238
|
+
// the Read tool will handle its own way. UNKNOWN IS NOT BIG.
|
|
239
|
+
recordFallback("unmeasurable", file);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (lines < threshold) return; // RUNG 4 — under the threshold
|
|
243
|
+
|
|
244
|
+
// RUNG 7 — `warn`: allow, and say so. `systemMessage` is shown to the user
|
|
245
|
+
// and is NOT added to model context, so the warning itself costs no tokens.
|
|
246
|
+
if (mode === "warn") {
|
|
247
|
+
traceLine("READ-GATE warn", `lines=${lines} max=${threshold} file=${path.basename(file)}`);
|
|
248
|
+
try {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
JSON.stringify({
|
|
251
|
+
systemMessage:
|
|
252
|
+
`📖 ORC read gate — ${path.basename(file)} is ${lines} lines ` +
|
|
253
|
+
`(threshold ${threshold}). Allowed: read_gate is 'warn'.\n` +
|
|
254
|
+
` Cheaper: Read with offset/limit, or dispatch an agent to read it and report back.`,
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
} catch (_) {}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// RUNG 8 — block, and NAME THE ALTERNATIVE. A block that only refuses
|
|
262
|
+
// teaches people to switch it off.
|
|
263
|
+
traceLine("READ-GATE block", `lines=${lines} max=${threshold} file=${path.basename(file)}`);
|
|
264
|
+
process.stderr.write(
|
|
265
|
+
`\n⛔ ORC read gate — ${path.basename(file)} is ${lines} lines, over the ` +
|
|
266
|
+
`${threshold}-line threshold.\n` +
|
|
267
|
+
` Reading it whole spends the orchestrator's context on material a ` +
|
|
268
|
+
`worker could summarise.\n\n` +
|
|
269
|
+
` Do one of these instead:\n` +
|
|
270
|
+
` • Read with offset/limit — the ladder's targeted read, and always allowed.\n` +
|
|
271
|
+
` • Grep for what you actually need, then read that range.\n` +
|
|
272
|
+
` • Dispatch an agent to read it and report back (its reads are never gated).\n\n` +
|
|
273
|
+
` If you genuinely need the whole file here: set read_gate to warn or off\n` +
|
|
274
|
+
` orc config set read_gate warn\n` +
|
|
275
|
+
` The gate is silent outside an ORC run, on targeted reads, on files a\n` +
|
|
276
|
+
` gate parses, and on every read a subagent makes.\n`
|
|
277
|
+
);
|
|
278
|
+
process.exit(2);
|
|
279
|
+
}
|