mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
Merge branch 'steward/security-20261011' of /Users/hira/Documents/ECC/ecc-review-security-20261011 into integration/ecc-043-20261011
This commit is contained in:
28 files changed
+3010
-315
No files matched your search
@@ -5,6 +5,7 @@
|
||||
### Changed
|
||||
|
||||
- GateGuard first-touch denials ask questions that fit the target: by class (instruction, test, prose, config, code) on its project-relative path, and for code by what the change touches. An edit that changes no public declaration asks for the in-module call sites instead of importers and public API, and the data-schema question is dropped when the change handles no data. Changes the hook cannot read with confidence keep the original four questions word for word.
|
||||
- GateGuard's external-contract question ("name what outside this repository decides the format, units, timezone or protocol semantics") is opt-in through `GATEGUARD_EXTERNAL_CONTRACT_QUESTION`. Hidden-intent trials did not show it helps, so by default a code change that handles data asks only the data-schema question, and an unreadable change gets the original four questions and condensed hint.
|
||||
- GateGuard credits investigation that already happened: a completed, non-error `Glob`, `Grep`, `LS`, or search-style Bash/PowerShell command in the current human turn that names the target and whose scope contains it passes the first touch with a note. `Read` never counts, nor do searches in the same batch, before a compaction, with error results, or whose filters exclude the target or limit the search to another directory. When a denial follows a search that nearly counted, it names that search and why it did not count.
|
||||
- GateGuard passes comment and whitespace-only edits of code, test and prose files with a note, without marking the file checked, so the next change that alters code is still gated.
|
||||
- GateGuard asks once for a batch of new files: after a new file is denied, later new files of the same class in the same real directory within the same human turn pass with a note.
|
||||
@@ -23,6 +24,8 @@
|
||||
- GateGuard keys checked files by canonical path, resolving relative targets against the tool `cwd`, so `a.py`, `./a.py`, and the absolute path (or differently cased and separated Windows spellings) share one first touch. Keys from earlier state files are still honoured.
|
||||
- GateGuard gates a MultiEdit call as its `tool_input.file_path` when its entries do not name their own path, which is the tool's own shape; such calls were never gated before, including on sensitive files.
|
||||
- GateGuard gates NotebookEdit as a first-touch edit of its `notebook_path`; notebooks were never gated before.
|
||||
- GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators.
|
||||
- GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment.
|
||||
|
||||
## 2.2.3 - 2026-10-01
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ where the deciding fact exists only in a simulated user's hidden intent, so an
|
||||
agent that does not ask can only guess. Scenario files omit declared gate
|
||||
question targets, but this does not establish blind or independent authorship:
|
||||
the authors knew the taxonomy, and the external-contract question was added
|
||||
after inspecting this corpus. Treat those results as exploratory. The report
|
||||
classifies behavior into descriptive hole categories.
|
||||
after inspecting this corpus. Treat those results as exploratory. That question
|
||||
is now opt-in (`GATEGUARD_EXTERNAL_CONTRACT_QUESTION`), and the runner clears
|
||||
every `GATEGUARD_*` variable from the agent's environment, so a `gate` arm
|
||||
built from a current candidate tree runs without it. The report classifies
|
||||
behavior into descriptive hole categories.
|
||||
|
||||
Each run writes `meta.json`, raw `results.jsonl`, transcripts, `summary.json`,
|
||||
`holes.md`, and `evidence.json`. The evidence manifest fingerprints the frozen
|
||||
|
||||
@@ -43,16 +43,16 @@ function applyArm(arm, targetClass) {
|
||||
const originalText = targetClass.questionText;
|
||||
const originalPhrase = targetClass.condensedQuestionPhrase;
|
||||
const originalHint = targetClass.condensedHintFor;
|
||||
const questionIdsFor = (cls, isWrite, profile) => armQuestionIds(arm, originalIds(cls, isWrite, profile));
|
||||
const questionIdsFor = (cls, isWrite, profile, options) => armQuestionIds(arm, originalIds(cls, isWrite, profile, options));
|
||||
const questionText = id => (Object.hasOwn(PLACEBO_TEXT, id) ? PLACEBO_TEXT[id] : originalText(id));
|
||||
const phraseFor = id => {
|
||||
if (Object.hasOwn(PLACEBO_PHRASES, id)) return PLACEBO_PHRASES[id];
|
||||
if (id === KEPT_ID) return VERBATIM_PHRASE;
|
||||
return originalPhrase(id) || originalText(id).replace(/ \(search the tree.*\)$/, '').replace(/^\w/, c => c.toLowerCase());
|
||||
};
|
||||
const condensedHintFor = (cls, isWrite, profile) => {
|
||||
const ids = questionIdsFor(cls, isWrite, profile);
|
||||
if (ids.join('\n') === originalIds(cls, isWrite, profile).join('\n')) return originalHint(cls, isWrite, profile);
|
||||
const condensedHintFor = (cls, isWrite, profile, options) => {
|
||||
const ids = questionIdsFor(cls, isWrite, profile, options);
|
||||
if (ids.join('\n') === originalIds(cls, isWrite, profile, options).join('\n')) return originalHint(cls, isWrite, profile, options);
|
||||
return `briefly state ${joinPhrases(ids.map(phraseFor).filter(Boolean))}, then retry.`;
|
||||
};
|
||||
Object.assign(targetClass, { questionIdsFor, questionText, condensedHintFor });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Agent-space distance metric & collision avoidance (Layer 4)
|
||||
# Agent-space proximity and advisory prototypes (Layer 4)
|
||||
|
||||
> Status: v0 implemented in `scripts/lib/agent-proximity/`. This is the moat
|
||||
> layer of ECC 2.0 — *spatial deconfliction for multiple agents (and humans)
|
||||
> working the same codebase*, modeled on aircraft collision avoidance (TCAS).
|
||||
> Status: v0 proximity scoring exists in `scripts/lib/agent-proximity/`.
|
||||
> A separate v1 shadow trail records bounded advisories and declared responses.
|
||||
> Current scores and drawings have no calibrated conflict probability or live
|
||||
> conflict-reduction result.
|
||||
|
||||
## The analogy
|
||||
|
||||
@@ -28,16 +29,16 @@ also declare an **intent set** *I_a* of files it is about to touch (look-ahead).
|
||||
|
||||
## 2. Collision is multi-channel (noisy-OR)
|
||||
|
||||
Two agents can collide through several independent channels. Each channel *i*
|
||||
yields a collision probability *r_i ∈ [0,1]*; we combine them as the probability
|
||||
of colliding through **at least one** channel:
|
||||
Several channels contribute bounded heuristic scores *r_i ∈ [0,1]*. The shipped
|
||||
noisy-OR-shaped combination is:
|
||||
|
||||
```
|
||||
R(a,b) = 1 − Π_i ( 1 − ω_i · r_i ) (2)
|
||||
```
|
||||
|
||||
with channel weights *ω_i ∈ [0,1]*. The reported **distance** is the dual
|
||||
*D(a,b) = 1 − R(a,b)*.
|
||||
with channel weights *ω_i ∈ [0,1]*. The reported **distance score** is the dual
|
||||
*D(a,b) = 1 − R(a,b)*. Correlated channels and unvalidated weights mean *R* is
|
||||
not a calibrated probability, and *D* need not satisfy metric axioms.
|
||||
|
||||
### Channel 1 — edit overlap *r_overlap*
|
||||
|
||||
@@ -119,10 +120,9 @@ rate near *τ_TA* can pre-emptively escalate before the protected zone is entere
|
||||
Each file gets a coordinate via a **space-filling embedding of its path** (files
|
||||
sharing a long directory prefix share most of their coordinate), then pulled
|
||||
toward its dependency neighbours by one averaging step. An agent sits at the
|
||||
recency-weighted centroid of its files' coordinates. The result: `‖v_a − v_b‖`
|
||||
tracks the collision risk *R*, so a **3D "where are the agents" view** renders
|
||||
agents as moving points in a file-cloud — you literally watch them crawl toward
|
||||
each other, see the advisory line light up, and watch one steer away.
|
||||
recency-weighted centroid of its files' coordinates. The resulting **3D display**
|
||||
illustrates path and dependency proximity. Its Euclidean separation has not
|
||||
been shown to preserve the risk score or predict collisions.
|
||||
|
||||
`scanAirspace(agents, graph)` returns, in one pass: the non-clear `advisories`
|
||||
(what the trigger layer acts on), the 3D `positions` and `fileCoordinates` (what
|
||||
@@ -133,18 +133,58 @@ the renderer draws), and pairwise `links` with risk (the edges to color).
|
||||
- **Inputs** come from the session/work state: each running session's worktree
|
||||
diff gives its working set *W_a*; the dependency graph is built from the repo
|
||||
(`buildDependencyGraph`).
|
||||
- **Triggers**: the control-pane tick calls `scanAirspace`; a Traffic Advisory
|
||||
injects a "transmit intent" message between the two agents' sessions; a
|
||||
Resolution Advisory tells the lower-priority agent to steer (re-target to a
|
||||
different file/subtree) — the first concrete realization of *just-in-time
|
||||
multi-agent (and multi-human) deconfliction*.
|
||||
- **Triggers**: control-pane prototype code can derive messages from
|
||||
`scanAirspace`. Delivery, acknowledgement, scope authorization and actual
|
||||
conflict resolution are separate evidence. The shadow trail below calls no
|
||||
sink, hook or steer path.
|
||||
- **Board**: advisories surface on the kanban as proximity warnings, extending
|
||||
the agent/human JIT assignment layer already in the control pane.
|
||||
|
||||
## Passive shadow receipt trail (v1)
|
||||
|
||||
`scripts/lib/agent-proximity/shadow-trail.js` accepts two supplied, versioned
|
||||
observations with task/repository/base/scope identity, read/write path sets,
|
||||
completeness and expiry. It computes a local advisory using the shipped
|
||||
three-channel scorer. An exact write/write or write/read path overlap takes
|
||||
precedence over a low score. Incomplete, expired, future or incomparable critical
|
||||
observations produce `unknown`; an absent advisory means only that no advisory
|
||||
was found **within the complete, comparable supplied scope and supplied dependency
|
||||
graph**. The optional graph defaults to empty and may be supplied anew for
|
||||
re-observation; a quiet advisory says nothing about unsupplied dependencies.
|
||||
|
||||
Call `createShadowTrail`, then `appendAcknowledgement` for both observed agents,
|
||||
`appendScopeRevision` for one externally declared revision and its new footprint,
|
||||
and `appendReobservation` with a fresh matching pair. `summarizeShadowTrail`
|
||||
reports the latest scoped advisory, its own advisory-bound ACKs, separate
|
||||
historical acknowledgers and `outcome: unadjudicated`. Both original observations
|
||||
must still be current when the scope-change declaration is recorded.
|
||||
`verifyShadowTrail` checks the bounded local receipt chain after JSON persistence.
|
||||
Each receipt hash binds the trail ID, event ID, time, predecessor hash and
|
||||
canonical payload. The verifier rejects noncanonical persisted payload field
|
||||
and path order before comparing hashes. Append results copy earlier receipts,
|
||||
so changing an earlier returned object cannot change a later returned trail.
|
||||
Returned data can still be mutated; verification detects inconsistent local
|
||||
content. A party able to
|
||||
rewrite and rehash the entire trail can forge a consistent chain. These hashes
|
||||
do not establish trusted identity, permission, delivery, whole-log immutability
|
||||
or an independently verified result.
|
||||
|
||||
The module issues no grants and sends no messages. The scope-change receipt's
|
||||
`authorizationRef` is a caller declaration and is labelled `declared-only`;
|
||||
the actual authority owner must validate it before any real work changes. It
|
||||
records path metadata locally, so a caller must apply its own publication policy.
|
||||
It accepts no memory summary or required-state payload. Approximate summaries
|
||||
belong to the separately owned memory envelope; exact task constraints and
|
||||
authority remain outside this heuristic score. The trail is limited to one
|
||||
two-agent change, so it cannot grant coherent ownership across a three-agent
|
||||
conflict component.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- v0 (done): tree + overlap + dependency channels, noisy-OR risk, TCAS advisories,
|
||||
priority/steer, 3D embedding, full test coverage.
|
||||
- v0 (source present): tree + overlap + dependency channels, noisy-OR-shaped
|
||||
risk score, advisory/priority prototype and display embedding.
|
||||
- v1 shadow (source present): passive two-agent observation/advisory/ACK/scope
|
||||
revision/re-observation receipts; no active steering or measured benefit.
|
||||
- v1: call-graph & symbol read/write channels; intent look-ahead; closure-rate
|
||||
escalation wired to live session diffs.
|
||||
- v2: cross-machine airspace over Tailscale (teammate agents enter the same
|
||||
|
||||
@@ -116,6 +116,21 @@ or a PowerShell file or web cmdlet (`Out-File`, `Get-Content`,
|
||||
`*-Clixml`; `Import-Csv` matches `csv`). Redirection is found without
|
||||
reading quotes, so a `>` inside a string also counts.
|
||||
|
||||
## External-contract question
|
||||
|
||||
`GATEGUARD_EXTERNAL_CONTRACT_QUESTION` (`1`, `true`, `on`, `enabled`,
|
||||
`enable` or `yes`) adds `external-contract` right after every `data-schema`
|
||||
in the table above: "Name what outside this repository decides the format,
|
||||
units, timezone or protocol semantics here (the consumer, the producer, or a
|
||||
stated convention), or state that the choice is unconstrained". The condensed
|
||||
hint and the decision metrics name it too. It is off by default because it
|
||||
has no evidence behind it: it was designed after inspecting the
|
||||
hidden-intent corpus, and in the rerun that added it the gated arm did not
|
||||
improve (see
|
||||
[question-effectiveness.md](question-effectiveness.md#adding-an-external-contract-question-did-not-help)).
|
||||
`questionIdsFor(class, isWrite, profile, { externalContract: true })` returns
|
||||
the ids with it.
|
||||
|
||||
## Trivial edits
|
||||
|
||||
An Edit is trivial when, for every entry, old and new have the same code once
|
||||
@@ -139,6 +154,9 @@ Anything the lexer cannot read with confidence makes the entry non-trivial:
|
||||
- a line comment ending in `\` (it continues onto the next line in C and
|
||||
Make), the trigraph `??/`, a `/*` inside a block comment, and an
|
||||
unterminated block comment or string;
|
||||
- U+2028, U+2029 or U+0085 anywhere in the text: JavaScript ends a line at
|
||||
the first two and C# at all three, so code after one inside a `//` comment
|
||||
runs, while the lexer splits lines only at `\n` and `\r`;
|
||||
- a code line ending in a line continuation `\` (trailing blanks included):
|
||||
a C macro, a Python explicit continuation or a string continued onto the
|
||||
next line joins that line, so adding or removing a comment-only line after
|
||||
@@ -293,7 +311,8 @@ questions were asked without storing text.
|
||||
| config | any | `config-reader`, `config-effect`, `no-plaintext-secrets`, `quote-instruction` |
|
||||
|
||||
`[data-schema]` is asked only when `touchesData`. With an unknown profile the
|
||||
code text is byte-identical to the fixed four questions. Sensitive and
|
||||
code text is byte-identical to the fixed four questions, unless the opt-in
|
||||
[external-contract question](#external-contract-question) is on. Sensitive and
|
||||
hard-linked targets and NotebookEdit calls are never profiled, so they always
|
||||
get the full code questions. Instruction and
|
||||
config questions carry no change-dependent item, and the test and prose items
|
||||
@@ -303,7 +322,8 @@ file (same canonical key); entries for other files do not count.
|
||||
|
||||
A condensed code denial names the same ids as a full one, one short phrase per
|
||||
id (`condensedQuestionPhrase`), so the data and duplicate checks follow the
|
||||
profile there too. The one exception is an Edit with an unknown profile, which
|
||||
keeps the original hint byte for byte ("importers/callers, affected API, data
|
||||
schemas if any"); a Write with an unknown profile, sensitive Writes included,
|
||||
profile there too. An Edit with an unknown profile keeps the original hint
|
||||
byte for byte ("importers/callers, affected API, data schemas if any") when
|
||||
the external-contract question is off. Opting in adds its external-contract
|
||||
phrase to that hint. A Write with an unknown profile, sensitive Writes included,
|
||||
names `callers`, `no-duplicate` and `data-schema` like its full denial.
|
||||
@@ -31,7 +31,8 @@ and trivial edits) is in [change-profile.md](change-profile.md).
|
||||
[Stdin is not a tree search](#stdin-is-not-a-tree-search),
|
||||
[Search scope](#search-scope), [Stem matching](#stem-matching),
|
||||
[Test stems](#test-stems), [Search filters](#search-filters),
|
||||
[Include filters](#include-filters),
|
||||
[Include filters](#include-filters), [Type filters](#type-filters),
|
||||
[Path patterns](#path-patterns),
|
||||
[Glob matching without RegExp](#glob-matching-without-regexp),
|
||||
[Flag parsing](#flag-parsing),
|
||||
[PowerShell parameter binding](#powershell-parameter-binding).
|
||||
@@ -207,7 +208,9 @@ their absolute path. A Windows-style path has no real location on a POSIX host
|
||||
The first matching class wins: instruction, test, prose, config, code (the
|
||||
fallback). Cursor `.mdc` rule files are instructions wherever they live. A
|
||||
basename starting with `.env` is config unless it has a code extension
|
||||
(`.env.example.ts` is source). Code targets ask the original four questions,
|
||||
(`.env.example.ts` is source). Code targets ask the original four questions
|
||||
(five with the opt-in
|
||||
[external-contract question](change-profile.md#external-contract-question)),
|
||||
narrowed by the change profile (see
|
||||
[Questions from the change profile](change-profile.md#questions-from-the-change-profile)); other
|
||||
classes get class-specific questions and a class-specific condensed hint.
|
||||
@@ -280,7 +283,12 @@ evidence.
|
||||
Each search offers stem texts, candidate directories, the scope the target must
|
||||
lie in, and path operands. The target must lie inside the scope. A search whose
|
||||
operands are all the target itself read the target rather than searched for it,
|
||||
and gives nothing. A `Glob` without glob characters is a single-file lookup: it
|
||||
and gives nothing. The same holds for a search confined to the target's own
|
||||
file name: when every include glob, or the Glob pattern, or every path pattern
|
||||
ends in the target's file name with no glob characters (`**/tokenizer.js`,
|
||||
`find -name tokenizer.js`, `rg -g tokenizer.js`), the search could only find
|
||||
the target, so its stem never credits; the closest-miss note says it was a
|
||||
lookup. A `Glob` without glob characters is a single-file lookup: it
|
||||
names no directory. A `Glob` with an empty literal prefix and no explicit path
|
||||
names only the implicit cwd, so it gives no directory credit. Directory
|
||||
evidence from shell operands must name a directory that exists now.
|
||||
@@ -349,6 +357,30 @@ leading `**/` lets it start at any directory; the other tools match names only,
|
||||
so such a glob admits nothing. An include glob that is malformed, past the
|
||||
matching bounds, or one of more than 32 filters admits nothing.
|
||||
|
||||
## Type filters
|
||||
|
||||
A file-type filter is an include or exclusion in another spelling, so it
|
||||
filters the same way. `rg -t`/`--type` adds the type's globs as includes and
|
||||
`rg -T`/`--type-not` adds them as exclusions, using ripgrep 14.1's built-in
|
||||
globs for common languages. A type name outside that table, `--type-add` and
|
||||
`--type-clear` redefine what a type means, so they make the search an
|
||||
unreadable filter that never credits. `fd -e`/`--extension` is an include of
|
||||
`*.<ext>`. The `Grep` tool's `type` parameter is read like `rg -t`.
|
||||
|
||||
## Path patterns
|
||||
|
||||
A glob in a path argument limits the files a search sees just as an include
|
||||
does, so it must match the target for the stem to count. The `Glob` tool's
|
||||
pattern is matched against the target's path below its root, with `*` inside
|
||||
one directory and `**` across directories. A shell path operand with glob
|
||||
characters (`rg x src/*.py`) is expanded by the shell before the search runs;
|
||||
it admits the target when it matches the target or a directory above it,
|
||||
since a matched directory is searched recursively. A git pathspec
|
||||
(`git grep x -- '*.py'`) matches the same way, except that its `*` also
|
||||
matches `/`. Literal operands keep their scope rule, and one operand that
|
||||
admits the target is enough. PowerShell escapes with a backtick, so a
|
||||
PowerShell search is tokenized with its backslashes kept as path separators.
|
||||
|
||||
## Glob matching without RegExp
|
||||
|
||||
Filter globs are matched by dynamic programming over glob tokens, never by a
|
||||
@@ -366,7 +398,8 @@ Only path operands can scope a search:
|
||||
- for grep-family tools, `rg`, `git grep`, `Select-String` and `fd`, the first
|
||||
positional is the pattern unless `-e`/`-f` (grep family, `rg`, `git grep`
|
||||
only; fd's `-e` is an extension) supplied it;
|
||||
- fd's `-E/--exclude` takes a value; rg's `-r/--replace` takes a value.
|
||||
- fd's `-E/--exclude` and `--extension` take a value; rg's `-r/--replace`,
|
||||
`--type-not`, `--type-add` and `--type-clear` take a value.
|
||||
|
||||
## PowerShell parameter binding
|
||||
|
||||
|
||||
@@ -529,6 +529,14 @@ and the corpus suggests that kind of answer matters. What is not claimed is
|
||||
that adding it improved anything measurable here. A fair test needs scenarios
|
||||
written by independent authors who have not seen that question or these results.
|
||||
|
||||
Because nothing here shows it helps, the question is now opt-in: it is asked
|
||||
only with `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` on (see
|
||||
[change-profile.md](change-profile.md#external-contract-question)). The
|
||||
harness clears every `GATEGUARD_*` variable from the agent's environment, so a
|
||||
`gate` arm built from a candidate tree that includes this change measures the
|
||||
gate without it, the "before the question" condition above. The runs reported
|
||||
here used candidate trees from before the change, where it was always asked.
|
||||
|
||||
**Standing conclusion.** Across 336 billed trials the gate's benefit is not
|
||||
established, its direction has been positive in the unflagged runs, and its cost
|
||||
is a consistent 35 to 45% in turns and spend. "Not confirmed" is not "no
|
||||
|
||||
@@ -18,6 +18,6 @@ $ARGUMENTS
|
||||
## Delegation
|
||||
|
||||
Apply the `context-budget` skill.
|
||||
- Pass through `--verbose` if the user supplied it.
|
||||
- Assume a 200K context window unless the user specified otherwise.
|
||||
- Return the skill's inventory, issue detection, and prioritized savings report without re-implementing the scan here.
|
||||
- Without an explicit `--audit`, return the harness's native live-context command and do not scan files or call tools.
|
||||
- Pass through `--audit` and `--verbose` only when the user supplied them.
|
||||
- Keep any explicit audit bounded to the scope and safety rules in the canonical skill.
|
||||
@@ -10,7 +10,7 @@
|
||||
"id": "hand-seeded",
|
||||
"source": "manual-curation-pending-regeneration"
|
||||
},
|
||||
"registryDigest": "16549b53055bf5c106e51ca7fb2fe68f8a705b5f6bad40d388349320caedfa63",
|
||||
"registryDigest": "55e9ce5724329ea6315394ed8969fc23d820f1163d82a9f3e1baef304e7ca30f",
|
||||
"schemaVersion": 1,
|
||||
"triggers": {
|
||||
"skill:agent-runtime-gateway-smoke-test": [
|
||||
|
||||
@@ -1945,9 +1945,14 @@ function batchSiblingWarning(safePath) {
|
||||
);
|
||||
}
|
||||
|
||||
// see docs/gateguard/change-profile.md#external-contract-question
|
||||
function questionOptions() {
|
||||
return { externalContract: ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_EXTERNAL_CONTRACT_QUESTION)) };
|
||||
}
|
||||
|
||||
function firstTouchGateMsg(filePath, isWrite, cls, profile) {
|
||||
const safe = sanitizePath(filePath);
|
||||
const questions = questionIdsFor(cls, isWrite, profile).map(questionText);
|
||||
const questions = questionIdsFor(cls, isWrite, profile, questionOptions()).map(questionText);
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
@@ -1968,7 +1973,7 @@ function firstTouchGateMsg(filePath, isWrite, cls, profile) {
|
||||
*/
|
||||
function condensedGateMsg(action, filePath, ordinal, cls = 'code', targetNote = '', profile = null, missNote = '') {
|
||||
const safe = sanitizePath(filePath);
|
||||
const hint = condensedHintFor(cls, action === 'creation', profile);
|
||||
const hint = condensedHintFor(cls, action === 'creation', profile, questionOptions());
|
||||
return (
|
||||
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
|
||||
`${hint} ` +
|
||||
@@ -2009,7 +2014,7 @@ function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, linked =
|
||||
cls,
|
||||
sensitive,
|
||||
profile,
|
||||
questions: questionIdsFor(cls, isWrite, profile)
|
||||
questions: questionIdsFor(cls, isWrite, profile, questionOptions())
|
||||
});
|
||||
if (denials > getFullDenialBudget()) {
|
||||
const action = isWrite ? 'creation' : 'edit';
|
||||
@@ -2117,6 +2122,7 @@ function creditNote(match, filePath) {
|
||||
const MISS_DETAIL_MAX_CHARS = 60;
|
||||
const MISS_REASON_TEXT = Object.freeze({
|
||||
'same-batch': 'it was sent in the same batch as this call, so its result was not seen yet',
|
||||
lookup: 'it only looked this file up by its own name, so it found nothing that uses it',
|
||||
excluded: 'its filters exclude this file',
|
||||
'out-of-scope': 'its search path does not contain this file',
|
||||
'stdin-only': 'it searched piped input, not the tree',
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
'use strict';
|
||||
|
||||
// Passive, local event trail. Receipt hashes detect accidental changes to these
|
||||
// bytes; they do not authenticate a producer, authorize a scope or prove delivery.
|
||||
const crypto = require('node:crypto');
|
||||
const { collisionRisk, DEFAULTS } = require('./distance');
|
||||
|
||||
const VERSION = 1;
|
||||
const ZERO_HASH = '0'.repeat(64);
|
||||
const MAX_RECEIPTS = 16;
|
||||
function invalid() { throw new Error('Invalid shadow coordination input.'); }
|
||||
function object(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) invalid();
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length > 64) invalid();
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
if (typeof key !== 'string' || !Object.hasOwn(value, key) ||
|
||||
!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function shape(value, allowed, required = allowed) {
|
||||
const row = object(value);
|
||||
if (Reflect.ownKeys(row).some(key => !allowed.includes(key)) ||
|
||||
required.some(key => !Object.hasOwn(row, key))) invalid();
|
||||
return row;
|
||||
}
|
||||
function array(value, max) {
|
||||
if (!Array.isArray(value) || value.length > max ||
|
||||
Object.getPrototypeOf(value) !== Array.prototype) invalid();
|
||||
const keys = Reflect.ownKeys(value);
|
||||
if (keys.length !== value.length + 1 || !keys.includes('length')) invalid();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
||||
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) invalid();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function identifier(value) {
|
||||
if (typeof value !== 'string' || value.length > 120 ||
|
||||
!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) ||
|
||||
['__proto__', 'constructor', 'prototype'].includes(value)) invalid();
|
||||
return value;
|
||||
}
|
||||
function timestamp(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
||||
!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== value) invalid();
|
||||
return value;
|
||||
}
|
||||
function path(value) {
|
||||
if (typeof value !== 'string' || !value || value.length > 256 ||
|
||||
value.startsWith('/') || /^[A-Za-z]:/.test(value) || value.includes('\\') ||
|
||||
value.split('/').some(part => !part || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(part)) ||
|
||||
[...value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) invalid();
|
||||
return value;
|
||||
}
|
||||
function paths(value, max = 32) {
|
||||
array(value, max);
|
||||
const result = value.map(path);
|
||||
if (new Set(result).size !== result.length) invalid();
|
||||
return result.sort();
|
||||
}
|
||||
function observation(value) {
|
||||
const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision', 'scopeRevision',
|
||||
'footprint', 'readPaths', 'writePaths', 'observedAt', 'expiresAt']);
|
||||
if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) ||
|
||||
!Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1 ||
|
||||
!['complete', 'incomplete'].includes(row.footprint)) invalid();
|
||||
const observedAt = timestamp(row.observedAt);
|
||||
const expiresAt = timestamp(row.expiresAt);
|
||||
if (Date.parse(expiresAt) <= Date.parse(observedAt)) invalid();
|
||||
return { agentId: identifier(row.agentId), taskId: identifier(row.taskId),
|
||||
repoId: identifier(row.repoId), baseRevision: row.baseRevision,
|
||||
scopeRevision: row.scopeRevision, footprint: row.footprint,
|
||||
readPaths: paths(row.readPaths), writePaths: paths(row.writePaths), observedAt, expiresAt };
|
||||
}
|
||||
function pair(value) {
|
||||
array(value, 2);
|
||||
if (value.length !== 2) invalid();
|
||||
const result = value.map(observation).sort((a, b) => a.agentId < b.agentId ? -1 : a.agentId > b.agentId ? 1 : 0);
|
||||
if (result[0].agentId === result[1].agentId) invalid();
|
||||
return result;
|
||||
}
|
||||
function graph(value = { adjacency: {} }) {
|
||||
const row = shape(value, ['adjacency']);
|
||||
const adjacency = object(row.adjacency);
|
||||
const entries = Object.entries(adjacency).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
||||
if (entries.length > 64) invalid();
|
||||
const normalized = Object.create(null);
|
||||
let edgeCount = 0;
|
||||
for (const [name, edges] of entries) {
|
||||
edgeCount += Array.isArray(edges) ? edges.length : 257;
|
||||
if (edgeCount > 256) invalid();
|
||||
normalized[path(name)] = paths(edges, 16);
|
||||
}
|
||||
return { adjacency: normalized };
|
||||
}
|
||||
function sameTask(a, b) {
|
||||
return a.agentId === b.agentId && a.taskId === b.taskId &&
|
||||
a.repoId === b.repoId && a.baseRevision === b.baseRevision;
|
||||
}
|
||||
function intersection(a, b) { return a.some(item => b.includes(item)); }
|
||||
function samePaths(a, b) { return a.length === b.length && a.every((item, index) => item === b[index]); }
|
||||
function directHazard(a, b) {
|
||||
if (intersection(a.writePaths, b.writePaths)) return 'write-write';
|
||||
if (intersection(a.writePaths, b.readPaths) || intersection(b.writePaths, a.readPaths)) return 'write-read';
|
||||
return null;
|
||||
}
|
||||
function advisory(observations, dependencyGraph, at) {
|
||||
const [a, b] = observations;
|
||||
const hazard = directHazard(a, b);
|
||||
const aAll = [...new Set([...a.readPaths, ...a.writePaths])].sort();
|
||||
const bAll = [...new Set([...b.readPaths, ...b.writePaths])].sort();
|
||||
const first = collisionRisk({ files: a.writePaths.map(name => ({ path: name })) },
|
||||
{ files: bAll.map(name => ({ path: name })) }, dependencyGraph).risk;
|
||||
const second = collisionRisk({ files: b.writePaths.map(name => ({ path: name })) },
|
||||
{ files: aAll.map(name => ({ path: name })) }, dependencyGraph).risk;
|
||||
const risk = Math.max(first, second);
|
||||
let reason = null;
|
||||
if (a.repoId !== b.repoId || a.baseRevision !== b.baseRevision) reason = 'incomparable-context';
|
||||
else if (observations.some(item => Date.parse(item.observedAt) > Date.parse(at) ||
|
||||
Date.parse(item.expiresAt) <= Date.parse(at))) reason = 'stale-or-future-observation';
|
||||
else if (observations.some(item => item.footprint === 'incomplete')) reason = 'incomplete-footprint';
|
||||
const state = reason ? 'unknown' : hazard || risk >= DEFAULTS.thresholds.ta
|
||||
? 'advisory' : 'no-advisory-in-scope';
|
||||
return { state, reason, hazard: reason === 'incomparable-context' ? null : hazard,
|
||||
heuristicRisk: risk, scoreKind: 'uncalibrated-three-channel-heuristic',
|
||||
authority: 'observation-only', outcome: 'unadjudicated' };
|
||||
}
|
||||
function encode(value) {
|
||||
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = [];
|
||||
for (let index = 0; index < value.length; index += 1) items.push(encode(value[index]));
|
||||
return `[${items.join(',')}]`;
|
||||
}
|
||||
if (!value || typeof value !== 'object') invalid();
|
||||
const fields = [];
|
||||
for (const key of Object.keys(value)) fields.push(`${JSON.stringify(key)}:${encode(value[key])}`);
|
||||
return `{${fields.join(',')}}`;
|
||||
}
|
||||
function digest(trailId, receipt, payload = receipt.payload) {
|
||||
const encoded = encode([VERSION, trailId, receipt.id, receipt.kind, receipt.at,
|
||||
receipt.previousHash, payload]);
|
||||
return crypto.createHash('sha256').update('ecc.tcas.shadow.v1\n').update(encoded).digest('hex');
|
||||
}
|
||||
function newReceipt(trail, id, kind, at, payload) {
|
||||
if (trail.receipts.length >= MAX_RECEIPTS) invalid();
|
||||
const previousHash = trail.receipts.at(-1)?.hash || ZERO_HASH;
|
||||
const row = { version: VERSION, id: identifier(id), kind, at: timestamp(at), previousHash, payload };
|
||||
return { ...row, hash: digest(trail.trailId, row) };
|
||||
}
|
||||
function extend(trail, id, kind, at, payload) {
|
||||
verifyShadowTrail(trail);
|
||||
const copied = copyTrail(trail);
|
||||
const next = { ...copied, receipts: [...copied.receipts, newReceipt(copied, id, kind, at, payload)] };
|
||||
verifyShadowTrail(next);
|
||||
return next;
|
||||
}
|
||||
function normalizePayload(kind, value) {
|
||||
if (kind === 'observation' || kind === 'reobservation') {
|
||||
const row = shape(value, ['observations', 'graph']);
|
||||
return { observations: pair(row.observations), graph: graph(row.graph) };
|
||||
}
|
||||
if (kind === 'advisory') {
|
||||
const row = shape(value, ['state', 'reason', 'hazard', 'heuristicRisk', 'scoreKind', 'authority', 'outcome']);
|
||||
if (!['unknown', 'advisory', 'no-advisory-in-scope'].includes(row.state) ||
|
||||
![null, 'incomparable-context', 'stale-or-future-observation',
|
||||
'incomplete-footprint'].includes(row.reason) ||
|
||||
![null, 'write-write', 'write-read'].includes(row.hazard) ||
|
||||
!Number.isFinite(row.heuristicRisk) || row.heuristicRisk < 0 || row.heuristicRisk > 1 ||
|
||||
row.scoreKind !== 'uncalibrated-three-channel-heuristic' ||
|
||||
row.authority !== 'observation-only' || row.outcome !== 'unadjudicated') invalid();
|
||||
return { state: row.state, reason: row.reason, hazard: row.hazard,
|
||||
heuristicRisk: row.heuristicRisk, scoreKind: row.scoreKind,
|
||||
authority: row.authority, outcome: row.outcome };
|
||||
}
|
||||
if (kind === 'acknowledgement') {
|
||||
const row = shape(value, ['agentId', 'scopeRevision', 'advisoryId']);
|
||||
if (!Number.isSafeInteger(row.scopeRevision) || row.scopeRevision < 1) invalid();
|
||||
return { agentId: identifier(row.agentId), scopeRevision: row.scopeRevision,
|
||||
advisoryId: identifier(row.advisoryId) };
|
||||
}
|
||||
if (kind === 'scope-revision') {
|
||||
const row = shape(value, ['agentId', 'taskId', 'repoId', 'baseRevision',
|
||||
'previousScopeRevision', 'newScopeRevision', 'newReadPaths', 'newWritePaths',
|
||||
'authorizationRef', 'authority']);
|
||||
if (typeof row.baseRevision !== 'string' || !/^[a-f0-9]{40}$/.test(row.baseRevision) ||
|
||||
!Number.isSafeInteger(row.previousScopeRevision) || row.previousScopeRevision < 1 ||
|
||||
!Number.isSafeInteger(row.newScopeRevision) || row.newScopeRevision <= row.previousScopeRevision ||
|
||||
row.authority !== 'declared-only') invalid();
|
||||
return { agentId: identifier(row.agentId), taskId: identifier(row.taskId),
|
||||
repoId: identifier(row.repoId), baseRevision: row.baseRevision,
|
||||
previousScopeRevision: row.previousScopeRevision, newScopeRevision: row.newScopeRevision,
|
||||
newReadPaths: paths(row.newReadPaths), newWritePaths: paths(row.newWritePaths),
|
||||
authorizationRef: identifier(row.authorizationRef), authority: row.authority };
|
||||
}
|
||||
invalid();
|
||||
}
|
||||
function copyTrail(trail) {
|
||||
return { version: VERSION, trailId: trail.trailId,
|
||||
receipts: trail.receipts.map(receipt => ({ version: receipt.version,
|
||||
id: receipt.id, kind: receipt.kind, at: receipt.at,
|
||||
previousHash: receipt.previousHash, payload: normalizePayload(receipt.kind, receipt.payload),
|
||||
hash: receipt.hash })) };
|
||||
}
|
||||
function transition(state, receipt) {
|
||||
const payload = normalizePayload(receipt.kind, receipt.payload);
|
||||
if (receipt.kind === 'observation') {
|
||||
if (state.observations) invalid();
|
||||
return { ...state, observations: payload.observations, graph: payload.graph, awaitingAdvisory: true };
|
||||
}
|
||||
if (receipt.kind === 'advisory') {
|
||||
if (!state.awaitingAdvisory || !state.observations) invalid();
|
||||
const expected = advisory(state.observations, state.graph, receipt.at);
|
||||
if (encode(payload) !== encode(expected)) invalid();
|
||||
return { ...state, latestAdvisory: expected, advisoryId: receipt.id,
|
||||
awaitingAdvisory: false, acknowledgements: [] };
|
||||
}
|
||||
if (state.awaitingAdvisory || !state.latestAdvisory) invalid();
|
||||
if (receipt.kind === 'acknowledgement') {
|
||||
if (state.latestAdvisory.state !== 'advisory' || state.scopeChange || state.reobserved ||
|
||||
payload.advisoryId !== state.advisoryId ||
|
||||
state.acknowledgements.includes(payload.agentId)) invalid();
|
||||
const member = state.observations.find(item => item.agentId === payload.agentId);
|
||||
if (!member || member.scopeRevision !== payload.scopeRevision ||
|
||||
Date.parse(member.expiresAt) <= Date.parse(receipt.at)) invalid();
|
||||
return { ...state, acknowledgements: [...state.acknowledgements, payload.agentId],
|
||||
acknowledgedBy: [...state.acknowledgedBy, payload.agentId] };
|
||||
}
|
||||
if (receipt.kind === 'scope-revision') {
|
||||
if (state.latestAdvisory.state !== 'advisory' || state.scopeChange ||
|
||||
state.reobserved || state.acknowledgements.length !== 2) invalid();
|
||||
const member = state.observations.find(item => item.agentId === payload.agentId);
|
||||
if (!member || !sameTask(member, payload) ||
|
||||
member.scopeRevision !== payload.previousScopeRevision ||
|
||||
state.observations.some(item => Date.parse(item.expiresAt) <= Date.parse(receipt.at) ||
|
||||
Date.parse(item.observedAt) > Date.parse(receipt.at))) invalid();
|
||||
return { ...state, scopeChange: { ...payload, at: receipt.at } };
|
||||
}
|
||||
if (receipt.kind === 'reobservation') {
|
||||
if (!state.scopeChange || state.reobserved) invalid();
|
||||
for (const member of payload.observations) {
|
||||
const old = state.observations.find(item => item.agentId === member.agentId);
|
||||
if (!old || !sameTask(old, member) ||
|
||||
member.scopeRevision !== (member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newScopeRevision : old.scopeRevision) ||
|
||||
!samePaths(member.readPaths, member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newReadPaths : old.readPaths) ||
|
||||
!samePaths(member.writePaths, member.agentId === state.scopeChange.agentId
|
||||
? state.scopeChange.newWritePaths : old.writePaths) ||
|
||||
Date.parse(member.observedAt) < Date.parse(state.scopeChange.at)) invalid();
|
||||
}
|
||||
return { ...state, observations: payload.observations, graph: payload.graph,
|
||||
reobserved: true, awaitingAdvisory: true };
|
||||
}
|
||||
invalid();
|
||||
}
|
||||
function inspect(trail) {
|
||||
const row = shape(trail, ['version', 'trailId', 'receipts']);
|
||||
if (row.version !== VERSION) invalid();
|
||||
array(row.receipts, MAX_RECEIPTS);
|
||||
if (row.receipts.length < 2) invalid();
|
||||
identifier(row.trailId);
|
||||
let previousHash = ZERO_HASH;
|
||||
let previousAt = -Infinity;
|
||||
let state = { observations: null, awaitingAdvisory: false,
|
||||
acknowledgements: [], acknowledgedBy: [] };
|
||||
const ids = new Set();
|
||||
for (const receipt of row.receipts) {
|
||||
shape(receipt, ['version', 'id', 'kind', 'at', 'previousHash', 'payload', 'hash']);
|
||||
const id = identifier(receipt.id);
|
||||
const at = timestamp(receipt.at);
|
||||
const payload = normalizePayload(receipt.kind, receipt.payload);
|
||||
if (encode(receipt.payload) !== encode(payload)) invalid();
|
||||
if (receipt.version !== VERSION || ids.has(id) || Date.parse(at) < previousAt ||
|
||||
typeof receipt.previousHash !== 'string' || typeof receipt.hash !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(receipt.previousHash) ||
|
||||
!/^[a-f0-9]{64}$/.test(receipt.hash) ||
|
||||
receipt.previousHash !== previousHash || receipt.hash !== digest(row.trailId, receipt, payload)) invalid();
|
||||
state = transition(state, { ...receipt, payload });
|
||||
ids.add(id);
|
||||
previousAt = Date.parse(at);
|
||||
previousHash = receipt.hash;
|
||||
}
|
||||
if (state.awaitingAdvisory) invalid();
|
||||
return state;
|
||||
}
|
||||
function guard(fn) {
|
||||
try { return fn(); } catch { invalid(); }
|
||||
}
|
||||
function verifyShadowTrail(trail) { return guard(() => { inspect(trail); return true; }); }
|
||||
function createShadowTrail(input) {
|
||||
const row = shape(input, ['trailId', 'observations', 'at', 'graph'],
|
||||
['trailId', 'observations', 'at']);
|
||||
const trailId = identifier(row.trailId);
|
||||
const at = timestamp(row.at);
|
||||
const observations = pair(row.observations);
|
||||
const dependencyGraph = graph(row.graph);
|
||||
const blank = { version: VERSION, trailId, receipts: [] };
|
||||
const first = newReceipt(blank, `${trailId}:observation-0`, 'observation', at,
|
||||
{ observations, graph: dependencyGraph });
|
||||
const partial = { ...blank, receipts: [first] };
|
||||
const second = newReceipt(partial, `${trailId}:advisory-0`, 'advisory', at,
|
||||
advisory(observations, dependencyGraph, at));
|
||||
const result = { ...blank, receipts: [first, second] };
|
||||
verifyShadowTrail(result);
|
||||
return result;
|
||||
}
|
||||
function appendAcknowledgement(trail, input) {
|
||||
const row = shape(input, ['id', 'agentId', 'scopeRevision', 'at']);
|
||||
const state = inspect(trail);
|
||||
return extend(trail, row.id, 'acknowledgement', row.at,
|
||||
{ agentId: row.agentId, scopeRevision: row.scopeRevision, advisoryId: state.advisoryId });
|
||||
}
|
||||
function appendScopeRevision(trail, input) {
|
||||
const row = shape(input, ['id', 'agentId', 'previousScopeRevision',
|
||||
'newScopeRevision', 'newReadPaths', 'newWritePaths', 'authorizationRef', 'at']);
|
||||
const state = inspect(trail);
|
||||
const member = state.observations.find(item => item.agentId === row.agentId);
|
||||
if (!member) invalid();
|
||||
return extend(trail, row.id, 'scope-revision', row.at,
|
||||
{ agentId: row.agentId, taskId: member.taskId, repoId: member.repoId,
|
||||
baseRevision: member.baseRevision, previousScopeRevision: row.previousScopeRevision,
|
||||
newScopeRevision: row.newScopeRevision, newReadPaths: paths(row.newReadPaths),
|
||||
newWritePaths: paths(row.newWritePaths), authorizationRef: row.authorizationRef,
|
||||
authority: 'declared-only' });
|
||||
}
|
||||
function appendReobservation(trail, input) {
|
||||
const row = shape(input, ['id', 'observations', 'at', 'graph'], ['id', 'observations', 'at']);
|
||||
const state = inspect(trail);
|
||||
const at = timestamp(row.at);
|
||||
const observations = pair(row.observations);
|
||||
const dependencyGraph = graph(row.graph === undefined ? state.graph : row.graph);
|
||||
if (trail.receipts.length > MAX_RECEIPTS - 2) invalid();
|
||||
const copied = copyTrail(trail);
|
||||
const first = newReceipt(copied, row.id, 'reobservation', at, { observations, graph: dependencyGraph });
|
||||
const partial = { ...copied, receipts: [...copied.receipts, first] };
|
||||
const second = newReceipt(partial, `${row.id}:advisory`, 'advisory', at,
|
||||
advisory(observations, dependencyGraph, at));
|
||||
const result = { ...copied, receipts: [...partial.receipts, second] };
|
||||
verifyShadowTrail(result);
|
||||
return result;
|
||||
}
|
||||
function summarizeShadowTrail(trail) {
|
||||
const state = inspect(trail);
|
||||
return { version: VERSION, mode: 'shadow-read-only', trailId: trail.trailId,
|
||||
receiptCount: trail.receipts.length, latestAdvisory: { ...state.latestAdvisory },
|
||||
latestAdvisoryId: state.advisoryId,
|
||||
latestAdvisoryAcknowledgedBy: [...state.acknowledgements].sort(),
|
||||
historicalAcknowledgedBy: [...new Set(state.acknowledgedBy)].sort(),
|
||||
scopeChange: state.scopeChange ? { ...state.scopeChange } : null,
|
||||
outcome: 'unadjudicated' };
|
||||
}
|
||||
module.exports = {
|
||||
createShadowTrail: input => guard(() => createShadowTrail(input)),
|
||||
appendAcknowledgement: (trail, input) => guard(() => appendAcknowledgement(trail, input)),
|
||||
appendScopeRevision: (trail, input) => guard(() => appendScopeRevision(trail, input)),
|
||||
appendReobservation: (trail, input) => guard(() => appendReobservation(trail, input)),
|
||||
verifyShadowTrail,
|
||||
summarizeShadowTrail: trail => guard(() => summarizeShadowTrail(trail))
|
||||
};
|
||||
@@ -301,7 +301,11 @@ function codeSignature(text, spec) {
|
||||
return signature;
|
||||
}
|
||||
|
||||
// JavaScript ends a line at U+2028 and U+2029, and C# also at U+0085; the lexer splits only at \n and \r.
|
||||
const UNICODE_LINE_TERMINATORS = /[\u0085\u2028\u2029]/;
|
||||
|
||||
function isTrivialEdit(oldString, newString, spec) {
|
||||
if (UNICODE_LINE_TERMINATORS.test(oldString) || UNICODE_LINE_TERMINATORS.test(newString)) return false;
|
||||
if (spec.lineComment === '//' && (oldString.includes('??/') || newString.includes('??/'))) return false;
|
||||
const before = codeSignature(oldString, spec);
|
||||
if (before === null) return false;
|
||||
|
||||
@@ -12,7 +12,9 @@ const {
|
||||
grepToolFilters,
|
||||
shellSearchFilters,
|
||||
filtersAdmitTarget,
|
||||
includesAdmitTarget
|
||||
includesAdmitTarget,
|
||||
pathPatternFor,
|
||||
confinedToTargetName
|
||||
} = require('./gateguard-search-filters');
|
||||
|
||||
// --- Search commands and flags ---
|
||||
@@ -141,6 +143,11 @@ function globLiteralPrefix(pattern) {
|
||||
return joined || (literal.length > 0 && pattern.startsWith('/') ? '/' : '');
|
||||
}
|
||||
|
||||
function grepDetail(input) {
|
||||
const flags = [['--glob', input.glob], ['--type', input.type]].filter(([, value]) => typeof value === 'string' && value);
|
||||
return [input.pattern].concat(flags.map(([flag, value]) => `${flag} ${value}`)).join(' ');
|
||||
}
|
||||
|
||||
function stringsOf(...values) {
|
||||
return values.filter(value => typeof value === 'string' && value);
|
||||
}
|
||||
@@ -165,16 +172,22 @@ function operandScopePath(arg) {
|
||||
return literal.join('/') || '/';
|
||||
}
|
||||
|
||||
function shellSearchScopes(parsed, base) {
|
||||
if (parsed.operands.length === 0) return base === null ? [] : [base];
|
||||
// see docs/gateguard/design-notes.md#path-patterns
|
||||
function shellSearchScopes(kind, parsed, base) {
|
||||
if (parsed.operands.length === 0) return { scopes: base === null ? [] : [base], patterns: [] };
|
||||
const scopes = [];
|
||||
const patterns = [];
|
||||
// A shell glob is expanded before the search runs; a git pathspec's `*` also matches `/`.
|
||||
const options = { crossSegments: kind.startsWith('git '), ancestors: true };
|
||||
for (const operand of parsed.operands) {
|
||||
if (!operand || operand === '-' || /^[$~]/.test(operand)) continue;
|
||||
const scoped = operandScopePath(operand);
|
||||
if (isAbsoluteShellPath(scoped)) scopes.push([scoped]);
|
||||
else if (base !== null) scopes.push(base.concat(scoped));
|
||||
const root = isAbsoluteShellPath(scoped) ? [] : base;
|
||||
if (root === null) continue;
|
||||
scopes.push(root.concat(scoped));
|
||||
patterns.push(operand.startsWith(':') ? null : pathPatternFor(root, operand, options));
|
||||
}
|
||||
return scopes;
|
||||
return { scopes, patterns };
|
||||
}
|
||||
|
||||
function isRecursiveFlag(kind, arg) {
|
||||
@@ -272,6 +285,12 @@ function evidenceInScope(item, ctx) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A search credits by stem when it names the stem, saw the target, and was not a lookup of the target itself.
|
||||
function stemCredits(item, ctx, stem) {
|
||||
if (!includesAdmitTarget(item, ctx) || confinedToTargetName(item, ctx)) return false;
|
||||
return item.texts.some(text => stem.test(text.toLowerCase()));
|
||||
}
|
||||
|
||||
function evidenceNamesDir(item, ctx) {
|
||||
return item.dirs.some(
|
||||
parts => ctx.resolveDir(...parts) === ctx.targetDir && (!item.dirsMustExist || isDirectoryNow(ctx.resolveNative(...parts)))
|
||||
@@ -281,7 +300,9 @@ function evidenceNamesDir(item, ctx) {
|
||||
// --- Prior-search credit ---
|
||||
|
||||
const MIN_GENERIC_MISS_STEM = 3;
|
||||
const MISS_RANK = Object.freeze({ 'same-batch': 0, excluded: 1, 'out-of-scope': 2, 'stdin-only': 3, 'not-a-search': 4, 'generic-stem': 5 });
|
||||
const MISS_RANK = Object.freeze({
|
||||
'same-batch': 0, lookup: 1, excluded: 2, 'out-of-scope': 3, 'stdin-only': 4, 'not-a-search': 5, 'generic-stem': 6
|
||||
});
|
||||
|
||||
/** Build the prior-search matcher around the hook's shell segmenter. */
|
||||
function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) {
|
||||
@@ -297,13 +318,15 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
return [{ texts: [input.pattern], dirs: [], scope, scopeExact: true, operands: [lookup], detail: input.pattern }];
|
||||
}
|
||||
const dirs = prefix || explicit ? [scope] : [];
|
||||
return [{ texts: [input.pattern], dirs, scope, detail: input.pattern }];
|
||||
// see docs/gateguard/design-notes.md#path-patterns
|
||||
const pathPattern = pathPatternFor([explicit || '.'], input.pattern);
|
||||
return [{ texts: [input.pattern], dirs, scope, detail: input.pattern, pathPattern }];
|
||||
}
|
||||
if (search.name === 'Grep') {
|
||||
if (typeof input.pattern !== 'string' || !input.pattern) return [];
|
||||
const explicit = typeof input.path === 'string' && input.path ? input.path : '';
|
||||
const detail = typeof input.glob === 'string' && input.glob ? `${input.pattern} --glob ${input.glob}` : input.pattern;
|
||||
const filters = grepToolFilters(input.glob);
|
||||
const detail = grepDetail(input);
|
||||
const filters = grepToolFilters(input.glob, input.type);
|
||||
return [{
|
||||
texts: stringsOf(input.pattern, ...filters.positives),
|
||||
dirs: explicit ? [[explicit]] : [],
|
||||
@@ -316,7 +339,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
if (search.name === 'LS') {
|
||||
return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir }));
|
||||
}
|
||||
return shellSearchEvidence(input.command, shellDirsTrusted, misses);
|
||||
return shellSearchEvidence(input.command, search.name, shellDirsTrusted, misses);
|
||||
}
|
||||
|
||||
function segmentPipeFlags(input) {
|
||||
@@ -351,9 +374,11 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
return flags;
|
||||
}
|
||||
|
||||
function shellSearchEvidence(command, dirsTrusted, misses) {
|
||||
if (typeof command !== 'string' || !command || command.length > MAX_SEARCH_COMMAND_CHARS) return [];
|
||||
if (AMBIGUOUS_SHELL_PATTERN.test(command)) return [];
|
||||
function shellSearchEvidence(rawCommand, toolName, dirsTrusted, misses) {
|
||||
if (typeof rawCommand !== 'string' || !rawCommand || rawCommand.length > MAX_SEARCH_COMMAND_CHARS) return [];
|
||||
if (AMBIGUOUS_SHELL_PATTERN.test(rawCommand)) return [];
|
||||
// PowerShell escapes with a backtick, so a backslash there is a path separator, not an escape.
|
||||
const command = toolName === 'PowerShell' ? rawCommand.replace(/\\/g, '\\\\') : rawCommand;
|
||||
const pipeFlags = segmentPipeFlags(command);
|
||||
const evidence = [];
|
||||
let trusted = dirsTrusted;
|
||||
@@ -381,7 +406,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
miss('stdin-only');
|
||||
return;
|
||||
}
|
||||
const scopes = shellSearchScopes(parsed, base);
|
||||
const { scopes, patterns } = shellSearchScopes(kind, parsed, base);
|
||||
if (scopes.length === 0) return;
|
||||
const filters = shellSearchFilters(kind, args);
|
||||
// see docs/gateguard/design-notes.md#search-filters
|
||||
@@ -392,6 +417,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
dirs,
|
||||
dirsMustExist: true,
|
||||
scopes,
|
||||
scopePatterns: patterns,
|
||||
operands: parsed.operands.map(op => [op]),
|
||||
detail: tokens.join(' '),
|
||||
exclusions: filters.exclusions,
|
||||
@@ -455,7 +481,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
if (shellDirsTrusted === null) shellDirsTrusted = shellDirsTrustedFor(scan);
|
||||
for (const item of cachedEvidence(search, shellDirsTrusted).items) {
|
||||
if (!evidenceInScope(item, ctx) || !filtersAdmitTarget(item, ctx, stem)) continue;
|
||||
const byStem = stem !== null && includesAdmitTarget(item, ctx) && item.texts.some(text => stem.test(text.toLowerCase()));
|
||||
const byStem = stem !== null && stemCredits(item, ctx, stem);
|
||||
const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx);
|
||||
if (byStem || byDir) {
|
||||
return { name: search.name, detail: item.detail, callsAgo: search.callsAgo };
|
||||
@@ -484,7 +510,9 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
|
||||
const inScope = evidenceInScope(item, ctx);
|
||||
const admitted = filtersAdmitTarget(item, ctx, stem);
|
||||
const included = includesAdmitTarget(item, ctx);
|
||||
if (inScope && admitted && (byDir || included)) return sameBatch ? 'same-batch' : null;
|
||||
const lookup = included && confinedToTargetName(item, ctx);
|
||||
if (inScope && admitted && (byDir || (included && !lookup))) return sameBatch ? 'same-batch' : null;
|
||||
if (inScope && admitted && lookup) return 'lookup';
|
||||
if (!inScope) return 'out-of-scope';
|
||||
return 'excluded';
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ const POWERSHELL_SEARCHES = new Set(['get-childitem', 'gci', 'select-string', 's
|
||||
|
||||
const VALUE_FLAGS = new Set([
|
||||
'-e', '-f', '-g', '-t', '-T', '-m', '-A', '-B', '-C',
|
||||
'--regexp', '--file', '--type', '--glob', '--iglob', '--include', '--exclude', '--exclude-dir', '--exclude-from',
|
||||
'--ignore-file', '--max-count', '--replace',
|
||||
'--regexp', '--file', '--type', '--type-not', '--type-add', '--type-clear', '--glob', '--iglob', '--include',
|
||||
'--exclude', '--exclude-dir', '--exclude-from', '--ignore-file', '--max-count', '--replace',
|
||||
'-name', '-iname', '-path', '-ipath', '-type', '-maxdepth', '-mindepth', '-newer', '-regex', '-size', '-user', '-group', '-perm'
|
||||
]);
|
||||
|
||||
@@ -15,7 +15,65 @@ const LS_VALUE_FLAGS = new Set(['-I', '--ignore', '--hide']);
|
||||
|
||||
const TREE_VALUE_FLAGS = new Set(['-L', '-P', '-I', '-o']);
|
||||
|
||||
const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E']);
|
||||
const FD_VALUE_FLAGS = new Set([...VALUE_FLAGS, '-E', '--extension']);
|
||||
|
||||
// see docs/gateguard/design-notes.md#type-filters
|
||||
// ripgrep 14.1 built-in types (`rg --type-list`) for common languages; any other name is unreadable.
|
||||
const RG_TYPE_GLOBS = Object.freeze({
|
||||
c: ['*.[chH]', '*.[chH].in', '*.cats'],
|
||||
cmake: ['*.cmake', 'CMakeLists.txt'],
|
||||
config: ['*.cfg', '*.conf', '*.config', '*.ini'],
|
||||
cpp: ['*.[ChH]', '*.[ChH].in', '*.[ch]pp', '*.[ch]pp.in', '*.[ch]xx', '*.[ch]xx.in', '*.cc', '*.cc.in', '*.hh', '*.hh.in', '*.inl'],
|
||||
cs: ['*.cs'],
|
||||
csharp: ['*.cs'],
|
||||
css: ['*.css', '*.scss'],
|
||||
csv: ['*.csv'],
|
||||
dart: ['*.dart'],
|
||||
docker: ['*Dockerfile*'],
|
||||
elixir: ['*.eex', '*.ex', '*.exs', '*.heex', '*.leex', '*.livemd'],
|
||||
erlang: ['*.erl', '*.hrl'],
|
||||
go: ['*.go'],
|
||||
graphql: ['*.graphql', '*.graphqls'],
|
||||
h: ['*.h', '*.hh', '*.hpp'],
|
||||
html: ['*.ejs', '*.htm', '*.html'],
|
||||
java: ['*.java', '*.jsp', '*.jspx', '*.properties'],
|
||||
js: ['*.cjs', '*.js', '*.jsx', '*.mjs', '*.vue'],
|
||||
json: ['*.json', '*.sarif', 'composer.lock'],
|
||||
jsonl: ['*.jsonl'],
|
||||
kotlin: ['*.kt', '*.kts'],
|
||||
lua: ['*.lua'],
|
||||
make: ['*.mak', '*.mk', '[Gg][Nn][Uu]makefile', '[Gg][Nn][Uu]makefile.am', '[Gg][Nn][Uu]makefile.in', '[Mm]akefile', '[Mm]akefile.am', '[Mm]akefile.in'],
|
||||
markdown: ['*.markdown', '*.md', '*.mdown', '*.mdwn', '*.mdx', '*.mkd', '*.mkdn'],
|
||||
md: ['*.markdown', '*.md', '*.mdown', '*.mdwn', '*.mdx', '*.mkd', '*.mkdn'],
|
||||
php: ['*.php', '*.php3', '*.php4', '*.php5', '*.php7', '*.php8', '*.pht', '*.phtml'],
|
||||
protobuf: ['*.proto'],
|
||||
ps: ['*.cdxml', '*.ps1', '*.ps1xml', '*.psd1', '*.psm1'],
|
||||
py: ['*.py', '*.pyi'],
|
||||
python: ['*.py', '*.pyi'],
|
||||
ruby: ['*.gemspec', '*.rb', '*.rbw', '.irbrc', 'Gemfile', 'Rakefile', 'config.ru'],
|
||||
rust: ['*.rs'],
|
||||
scala: ['*.sbt', '*.scala'],
|
||||
sh: [
|
||||
'*.bash', '*.bashrc', '*.csh', '*.cshrc', '*.ksh', '*.kshrc', '*.sh', '*.tcsh', '*.zsh', '.bash_login', '.bash_logout',
|
||||
'.bash_profile', '.bashrc', '.cshrc', '.kshrc', '.login', '.logout', '.profile', '.tcshrc', '.zlogin', '.zlogout',
|
||||
'.zprofile', '.zshenv', '.zshrc', 'bash_login', 'bash_logout', 'bash_profile', 'bashrc', 'profile', 'zlogin', 'zlogout',
|
||||
'zprofile', 'zshenv', 'zshrc'
|
||||
],
|
||||
sql: ['*.psql', '*.sql'],
|
||||
svelte: ['*.svelte'],
|
||||
swift: ['*.swift'],
|
||||
toml: ['*.toml', 'Cargo.lock'],
|
||||
ts: ['*.cts', '*.mts', '*.ts', '*.tsx'],
|
||||
txt: ['*.txt'],
|
||||
typescript: ['*.cts', '*.mts', '*.ts', '*.tsx'],
|
||||
vue: ['*.vue'],
|
||||
xml: ['*.dtd', '*.rng', '*.sch', '*.xhtml', '*.xjb', '*.xml', '*.xml.dist', '*.xsd', '*.xsl', '*.xslt'],
|
||||
yaml: ['*.yaml', '*.yml']
|
||||
});
|
||||
|
||||
function rgTypeGlobs(name) {
|
||||
return Object.prototype.hasOwnProperty.call(RG_TYPE_GLOBS, name) ? RG_TYPE_GLOBS[name] : null;
|
||||
}
|
||||
|
||||
const POWERSHELL_FILTER_PARAMS = new Set(['filter', 'include', 'exclude']);
|
||||
|
||||
@@ -246,6 +304,8 @@ function includeAdmitsTarget(include, item, ctx) {
|
||||
|
||||
// see docs/gateguard/design-notes.md#include-filters
|
||||
function includesAdmitTarget(item, ctx) {
|
||||
if (item.pathPattern && !pathPatternAdmitsTarget(item.pathPattern, ctx)) return false;
|
||||
if (!operandPatternsAdmitTarget(item, ctx)) return false;
|
||||
const includes = Array.isArray(item.includes) ? item.includes : [];
|
||||
if (includes.length === 0) return true;
|
||||
if (includes.length > MAX_FILTERS_PER_SEARCH) return false;
|
||||
@@ -256,8 +316,70 @@ function isBasenameGlob(glob) {
|
||||
return !/[\\/]/.test(String(glob).replace(/^(?:\*\*[\\/])+/, ''));
|
||||
}
|
||||
|
||||
function grepToolFilters(glob) {
|
||||
// --- Path patterns ---
|
||||
// see docs/gateguard/design-notes.md#path-patterns
|
||||
|
||||
const PATH_GLOB_CHARS = /[*?[{]/;
|
||||
|
||||
/**
|
||||
* The pattern a path argument applies, split at its literal directory prefix,
|
||||
* or null when it has no glob characters. `root` is the prefix as path parts.
|
||||
*/
|
||||
function pathPatternFor(root, pattern, { crossSegments = false, ancestors = false } = {}) {
|
||||
const segments = String(pattern).split(/[\\/]/);
|
||||
const firstGlob = segments.findIndex(segment => PATH_GLOB_CHARS.test(segment));
|
||||
if (firstGlob === -1) return null;
|
||||
const literal = segments.slice(0, firstGlob).join('/') || (firstGlob > 0 ? '/' : '');
|
||||
return { root: literal ? root.concat(literal) : root, glob: segments.slice(firstGlob).join('/'), crossSegments, ancestors };
|
||||
}
|
||||
|
||||
function pathPatternAdmitsTarget(pattern, ctx) {
|
||||
const root = ctx.resolveDir(...pattern.root).replace(/\/+$/, '');
|
||||
if (!isInsideDir(ctx.targetKey, root)) return false;
|
||||
const rel = ctx.targetKey.slice(root.length + 1).toLowerCase();
|
||||
let glob = normalizeFilterGlob(pattern.glob);
|
||||
if (pattern.crossSegments) glob = glob.replace(/\*+/g, '**');
|
||||
const candidates = [rel];
|
||||
if (pattern.ancestors) {
|
||||
for (let at = rel.lastIndexOf('/'); at > 0; at = rel.lastIndexOf('/', at - 1)) candidates.push(rel.slice(0, at));
|
||||
}
|
||||
return candidates.some(text => globMatches(glob, text, false) === true);
|
||||
}
|
||||
|
||||
// One alternative per path operand: a literal operand admits what its scope covers, a pattern what it matches.
|
||||
function operandPatternsAdmitTarget(item, ctx) {
|
||||
const patterns = Array.isArray(item.scopePatterns) ? item.scopePatterns : [];
|
||||
if (!patterns.some(Boolean) || !Array.isArray(item.scopes)) return true;
|
||||
return item.scopes.some((parts, index) => {
|
||||
if (patterns[index]) return pathPatternAdmitsTarget(patterns[index], ctx);
|
||||
const scope = ctx.resolveDir(...parts);
|
||||
return scope === ctx.targetKey || isInsideDir(ctx.targetKey, scope);
|
||||
});
|
||||
}
|
||||
|
||||
function literalBasename(glob) {
|
||||
const last = normalizeFilterGlob(glob).split('/').pop();
|
||||
return last && !PATH_GLOB_CHARS.test(last) ? last : null;
|
||||
}
|
||||
|
||||
// see docs/gateguard/design-notes.md#search-scope
|
||||
/** Whether every include or path pattern that limits the search names only the target's own file name. */
|
||||
function confinedToTargetName(item, ctx) {
|
||||
const name = (ctx.targetKey.split('/').pop() || '').toLowerCase();
|
||||
const namesOnly = globs => globs.length > 0 && globs.every(glob => literalBasename(glob) === name);
|
||||
if (namesOnly(Array.isArray(item.includes) ? item.includes : [])) return true;
|
||||
if (item.pathPattern && namesOnly([item.pathPattern.glob])) return true;
|
||||
const patterns = Array.isArray(item.scopePatterns) ? item.scopePatterns : [];
|
||||
return patterns.length > 0 && patterns.every(Boolean) && namesOnly(patterns.map(pattern => pattern.glob));
|
||||
}
|
||||
|
||||
function grepToolFilters(glob, type) {
|
||||
const filters = { positives: [], includes: [], exclusions: [], opaque: false };
|
||||
if (typeof type === 'string' && type) {
|
||||
const globs = rgTypeGlobs(type);
|
||||
if (globs) filters.includes.push(...globs);
|
||||
else filters.opaque = true;
|
||||
}
|
||||
if (typeof glob !== 'string' || !glob) return filters;
|
||||
for (const part of splitTopLevelCommas(glob)) {
|
||||
if (part.startsWith('!')) filters.exclusions.push(part.slice(1));
|
||||
@@ -293,6 +415,13 @@ function applyShellFilterArg(kind, args, i, filters) {
|
||||
const include = value => {
|
||||
if (value) filters.includes.push(value);
|
||||
};
|
||||
const type = (name, indexes, excluded) => {
|
||||
const globs = rgTypeGlobs(name);
|
||||
if (!globs) filters.opaque = true;
|
||||
else if (excluded) globs.forEach(glob => exclude(glob, indexes));
|
||||
else globs.forEach(include);
|
||||
};
|
||||
const extension = value => include(value ? `*.${String(value).replace(/^\./, '')}` : '');
|
||||
if (kind.startsWith('git ') && GIT_EXCLUDE_PATHSPEC.test(arg)) {
|
||||
exclude(arg.replace(GIT_EXCLUDE_PATHSPEC, ''), [i]);
|
||||
return 0;
|
||||
@@ -303,25 +432,7 @@ function applyShellFilterArg(kind, args, i, filters) {
|
||||
const hasNext = eq <= 0 && i + 1 < args.length;
|
||||
const value = eq > 0 ? arg.slice(eq + 1) : hasNext ? args[i + 1] : '';
|
||||
const indexes = eq > 0 || !hasNext ? [i] : [i, i + 1];
|
||||
const consumed = indexes.length - 1;
|
||||
if (OPAQUE_FILTER_FLAGS.has(name)) {
|
||||
filters.opaque = true;
|
||||
return consumed;
|
||||
}
|
||||
if (name === '--glob' || name === '--iglob') {
|
||||
if (value.startsWith('!')) exclude(value.slice(1), indexes);
|
||||
else include(value);
|
||||
return consumed;
|
||||
}
|
||||
if (name === '--include') {
|
||||
include(value);
|
||||
return consumed;
|
||||
}
|
||||
if (EXCLUDE_LONG_FLAGS.has(name) && (kind === 'ls' || !LS_ONLY_EXCLUDE_FLAGS.has(name))) {
|
||||
exclude(value, indexes);
|
||||
return consumed;
|
||||
}
|
||||
return 0;
|
||||
return applyLongFilter(kind, name, value, indexes, { filters, exclude, include, type, extension }) ? indexes.length - 1 : 0;
|
||||
}
|
||||
if (!/^-[A-Za-z]/.test(arg)) return 0;
|
||||
const letters = shortFilterLetters(kind);
|
||||
@@ -336,23 +447,48 @@ function applyShellFilterArg(kind, args, i, filters) {
|
||||
const value = attached || args[i + 1] || '';
|
||||
const indexes = attached || i + 1 >= args.length ? [i] : [i, i + 1];
|
||||
const consumed = indexes.length - 1;
|
||||
applyShortFilter(kind, letter, value, indexes, { filters, exclude, include });
|
||||
applyShortFilter(kind, letter, value, indexes, { filters, exclude, include, type, extension });
|
||||
return consumed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Apply one long filter flag; false when the flag is not a filter. */
|
||||
function applyLongFilter(kind, name, value, indexes, { filters, exclude, include, type, extension }) {
|
||||
if (OPAQUE_FILTER_FLAGS.has(name) || (kind === 'rg' && (name === '--type-add' || name === '--type-clear'))) {
|
||||
filters.opaque = true;
|
||||
} else if (kind === 'rg' && (name === '--type' || name === '--type-not')) {
|
||||
type(value, indexes, name === '--type-not');
|
||||
} else if (kind === 'fd' && name === '--extension') {
|
||||
extension(value);
|
||||
} else if (name === '--glob' || name === '--iglob') {
|
||||
if (value.startsWith('!')) exclude(value.slice(1), indexes);
|
||||
else include(value);
|
||||
} else if (name === '--include') {
|
||||
include(value);
|
||||
} else if (EXCLUDE_LONG_FLAGS.has(name) && (kind === 'ls' || !LS_ONLY_EXCLUDE_FLAGS.has(name))) {
|
||||
exclude(value, indexes);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shortFilterLetters(kind) {
|
||||
if (kind === 'rg') return new Set(['g']);
|
||||
if (kind === 'fd') return new Set(['E']);
|
||||
if (kind === 'rg') return new Set(['g', 't', 'T']);
|
||||
if (kind === 'fd') return new Set(['E', 'e']);
|
||||
if (kind === 'ls') return new Set(['I']);
|
||||
if (kind === 'tree') return new Set(['I', 'P']);
|
||||
if (kind === 'git ls-files') return new Set(['x', 'X']);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function applyShortFilter(kind, letter, value, indexes, { filters, exclude, include }) {
|
||||
if (kind === 'rg') {
|
||||
function applyShortFilter(kind, letter, value, indexes, { filters, exclude, include, type, extension }) {
|
||||
if (kind === 'rg' && (letter === 't' || letter === 'T')) {
|
||||
type(value, indexes, letter === 'T');
|
||||
} else if (kind === 'fd' && letter === 'e') {
|
||||
extension(value);
|
||||
} else if (kind === 'rg') {
|
||||
if (value.startsWith('!')) exclude(value.slice(1), indexes);
|
||||
else include(value);
|
||||
} else if (kind === 'tree' && letter === 'P') {
|
||||
@@ -455,5 +591,7 @@ module.exports = {
|
||||
grepToolFilters,
|
||||
shellSearchFilters,
|
||||
filtersAdmitTarget,
|
||||
includesAdmitTarget
|
||||
includesAdmitTarget,
|
||||
pathPatternFor,
|
||||
confinedToTargetName
|
||||
};
|
||||
@@ -186,7 +186,7 @@ const CLASS_QUESTIONS = Object.fromEntries(
|
||||
);
|
||||
|
||||
// see docs/gateguard/change-profile.md#questions-from-the-change-profile
|
||||
function codeQuestionIds(isWrite, profile) {
|
||||
function codeQuestionIds(isWrite, profile, externalContract) {
|
||||
const known = Boolean(profile) && profile.known === true;
|
||||
const surface = !known || profile.touchesPublicSurface !== false;
|
||||
const data = !known || profile.touchesData !== false;
|
||||
@@ -194,8 +194,14 @@ function codeQuestionIds(isWrite, profile) {
|
||||
// A change that handles data is usually constrained by something outside the
|
||||
// tree - a consumer's dialect, a reporting timezone, a protocol's semantics -
|
||||
// and the repository cannot settle it. data-schema asks what the change
|
||||
// touches; external-contract asks who decides what it has to be.
|
||||
return data ? [...opening, 'data-schema', 'external-contract'] : opening;
|
||||
// touches; external-contract asks who decides what it has to be. It is
|
||||
// opt-in: see docs/gateguard/change-profile.md#external-contract-question.
|
||||
if (!data) return opening;
|
||||
return externalContract ? [...opening, 'data-schema', 'external-contract'] : [...opening, 'data-schema'];
|
||||
}
|
||||
|
||||
function asksExternalContract(options) {
|
||||
return Boolean(options) && options.externalContract === true;
|
||||
}
|
||||
|
||||
/** True when a class's questions depend on the change profile. */
|
||||
@@ -203,9 +209,14 @@ function questionsUseProfile(cls) {
|
||||
return !Object.hasOwn(CLASS_QUESTION_IDS, cls);
|
||||
}
|
||||
|
||||
/** Stable ids of the first-touch questions for a class, action and change profile. */
|
||||
function questionIdsFor(cls, isWrite, profile) {
|
||||
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls) ? CLASS_QUESTION_IDS[cls](Boolean(isWrite)) : codeQuestionIds(Boolean(isWrite), profile);
|
||||
/**
|
||||
* Stable ids of the first-touch questions for a class, action and change profile.
|
||||
* `options.externalContract === true` adds the opt-in external-contract question.
|
||||
*/
|
||||
function questionIdsFor(cls, isWrite, profile, options) {
|
||||
const ids = Object.hasOwn(CLASS_QUESTION_IDS, cls)
|
||||
? CLASS_QUESTION_IDS[cls](Boolean(isWrite))
|
||||
: codeQuestionIds(Boolean(isWrite), profile, asksExternalContract(options));
|
||||
return [...ids, 'quote-instruction'];
|
||||
}
|
||||
|
||||
@@ -215,6 +226,8 @@ function questionText(id) {
|
||||
}
|
||||
|
||||
const CODE_CONDENSED_HINT =
|
||||
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry.";
|
||||
const CODE_CONDENSED_HINT_EXTERNAL_CONTRACT =
|
||||
"briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry.";
|
||||
|
||||
const CONDENSED_PHRASES = Object.freeze({
|
||||
@@ -252,10 +265,12 @@ const CLASS_CONDENSED_HINTS = {
|
||||
};
|
||||
|
||||
/** One-line condensed hint matching `questionIdsFor`. */
|
||||
function condensedHintFor(cls, isWrite, profile) {
|
||||
function condensedHintFor(cls, isWrite, profile, options) {
|
||||
if (Object.hasOwn(CLASS_CONDENSED_HINTS, cls)) return CLASS_CONDENSED_HINTS[cls](Boolean(isWrite));
|
||||
if (!isWrite && !(profile && profile.known === true)) return CODE_CONDENSED_HINT;
|
||||
const phrases = questionIdsFor(cls, isWrite, profile).map(condensedQuestionPhrase).filter(Boolean);
|
||||
if (!isWrite && !(profile && profile.known === true)) {
|
||||
return asksExternalContract(options) ? CODE_CONDENSED_HINT_EXTERNAL_CONTRACT : CODE_CONDENSED_HINT;
|
||||
}
|
||||
const phrases = questionIdsFor(cls, isWrite, profile, options).map(condensedQuestionPhrase).filter(Boolean);
|
||||
return `briefly state ${joinPhrases(phrases)}, then retry.`;
|
||||
}
|
||||
|
||||
|
||||
+67
-119
@@ -1,49 +1,88 @@
|
||||
---
|
||||
name: context-budget
|
||||
description: Audits Claude Code context window consumption across agents, skills, MCP servers, and rules. Identifies bloat, redundant components, and produces prioritized token-savings recommendations. Use when the context window is filling up too fast and the agents, skills, MCP servers, or rules consuming it need to be identified.
|
||||
description: Routes live context checks to the native Codex or Claude Code meter without scanning files, and performs a bounded static component audit only when explicitly requested. Use for context-budget requests about remaining capacity or installed context overhead.
|
||||
metadata:
|
||||
origin: ECC
|
||||
---
|
||||
|
||||
# Context Budget
|
||||
|
||||
Analyze token overhead across every loaded component in a Claude Code session and surface actionable optimizations to reclaim context space.
|
||||
Use the provider's native meter for live session usage. Keep the separate,
|
||||
estimated installation audit behind an explicit opt-in.
|
||||
|
||||
## When to Use
|
||||
## Default: Live Context
|
||||
|
||||
- Session performance feels sluggish or output quality is degrading
|
||||
- You've recently added many skills, agents, or MCP servers
|
||||
- You want to know how much context headroom you actually have
|
||||
- Planning to add more components and need to know if there's room
|
||||
- Running `/context-budget` command (this skill backs it)
|
||||
Treat an invocation with no audit request as a live-context request. This
|
||||
includes requests for the current window, remaining capacity, headroom, token
|
||||
usage, or a context breakdown.
|
||||
|
||||
## How It Works
|
||||
For this default path:
|
||||
|
||||
### Phase 1: Inventory
|
||||
- Do not call or use tools.
|
||||
- Do not scan or read files.
|
||||
- Do not run shell commands, provider CLIs, debug logging, or MCP discovery.
|
||||
- Reply with only the native command and one short explanation.
|
||||
|
||||
Scan all component directories and estimate token consumption:
|
||||
Route by the active harness:
|
||||
|
||||
**Agents** (`agents/*.md`)
|
||||
- Count lines and tokens per file (words × 1.3)
|
||||
- Extract `description` frontmatter length
|
||||
- Flag: files >200 lines (heavy), description >30 words (bloated frontmatter)
|
||||
| Harness | Native command | Guidance |
|
||||
| --- | --- | --- |
|
||||
| Codex CLI | `/status` | Shows current token usage and remaining context capacity. Use `/statusline` and enable **Context stats** plus **Token counters** for a persistent footer meter. |
|
||||
| Codex app | `/status` | Shows current thread context usage and rate limits. |
|
||||
| Claude Code | `/context` | Shows the live context breakdown. Use `/context all` for expanded items. |
|
||||
| Other or unknown | None verified | State that ECC cannot read live provider state and ask which harness is active. |
|
||||
|
||||
**Skills** (`skills/*/SKILL.md`)
|
||||
- Count tokens per SKILL.md
|
||||
- Flag: files >400 lines
|
||||
- Check for duplicate copies in `.agents/skills/` — skip identical copies to avoid double-counting
|
||||
These commands are documented in the [Codex command reference](https://developers.openai.com/codex/cli/slash-commands) and [Claude Code command reference](https://code.claude.com/docs/en/commands).
|
||||
|
||||
**Rules** (`rules/**/*.md`)
|
||||
- Count tokens per file
|
||||
- Flag: files >100 lines
|
||||
- Detect content overlap between rule files in the same language module
|
||||
A skill cannot invoke a TUI slash command on the user's behalf. Never replace
|
||||
the native command with an estimated scan and never infer an audit request from
|
||||
phrases such as “context is heavy” or “how much is left.”
|
||||
|
||||
**MCP Servers** (`.mcp.json` or active MCP config)
|
||||
- Count configured servers and total tool count
|
||||
- Estimate schema overhead at ~500 tokens per tool
|
||||
- Flag: servers with >20 tools, servers that wrap simple CLI commands (`gh`, `git`, `npm`, `supabase`, `vercel`)
|
||||
## Explicit Static Audit
|
||||
|
||||
**Persisted-record bytes** (optional)
|
||||
Run an installation/configuration audit only when the user explicitly says
|
||||
`--audit`, “audit installed components,” or equivalent unambiguous wording.
|
||||
`--verbose` changes report detail; it does not opt into an audit by itself.
|
||||
|
||||
If the audit scope is ambiguous, ask for the harness and installation root
|
||||
before reading anything. Keep every audit bounded and read-only:
|
||||
|
||||
1. Inspect only the named plugin/install root and the named harness's known
|
||||
configuration files.
|
||||
2. Never recursively enumerate the workspace root, home directory, provider
|
||||
cache, transcripts, debug logs, or unrelated worktrees.
|
||||
3. Never launch a provider CLI, MCP server, or debug session to count tools.
|
||||
4. Cap inventory output at 50 entries per component class. Summarize the rest.
|
||||
5. Do not write temporary logs or copy configuration content into the report.
|
||||
|
||||
Audit only the component metadata relevant to eager discovery:
|
||||
|
||||
- agent and skill descriptions;
|
||||
- command descriptions when the harness discovers them eagerly;
|
||||
- always-loaded instruction files in the explicitly named scope;
|
||||
- configured MCP tool schemas when an existing bounded manifest exposes them.
|
||||
|
||||
Full skill, agent, command, and rule bodies are generally on-demand content.
|
||||
Report their disk size separately from estimated eager overhead instead of
|
||||
adding every body to the session baseline.
|
||||
|
||||
## Audit Output
|
||||
|
||||
Return a compact report containing:
|
||||
|
||||
- harness and exact paths inspected;
|
||||
- estimated eager/discovery overhead by component class;
|
||||
- installed counts and the heaviest metadata entries;
|
||||
- assumptions, unavailable measurements, and up to three optimizations.
|
||||
|
||||
Every calculated token number is an estimate of static configuration overhead,
|
||||
not live session context. Do not claim that an estimate is provider-reported,
|
||||
current, exact, or evidence of realized savings. Direct the user back to the
|
||||
native live-context command for current usage.
|
||||
|
||||
## Optional Selected-Record Diagnostic
|
||||
|
||||
Inspect a persisted record only when the user explicitly selects that file for this diagnostic. This is separate from the default live-context request and the installation audit.
|
||||
|
||||
For a local file diagnostic, explicitly select a stable, regular JSONL file or a snapshot you
|
||||
intend to inspect. Replace the example path below; this does not find or reconnect a session.
|
||||
@@ -93,94 +132,3 @@ These counts do not establish active context, remaining room, token usage, billi
|
||||
reconnect loads. For current harness-reported context and usage, use the version-appropriate
|
||||
[`/context` and `/usage` commands (`/cost` is an alias)](https://code.claude.com/docs/en/commands).
|
||||
|
||||
**CLAUDE.md** (project + user-level)
|
||||
- Count tokens per file in the CLAUDE.md chain
|
||||
- Flag: combined total >300 lines
|
||||
|
||||
### Phase 2: Classify
|
||||
|
||||
Sort every component into a bucket:
|
||||
|
||||
| Bucket | Criteria | Action |
|
||||
|--------|----------|--------|
|
||||
| **Always needed** | Referenced in CLAUDE.md, backs an active command, or matches current project type | Keep |
|
||||
| **Sometimes needed** | Domain-specific (e.g. language patterns), not referenced in CLAUDE.md | Consider on-demand activation |
|
||||
| **Rarely needed** | No command reference, overlapping content, or no obvious project match | Remove or lazy-load |
|
||||
|
||||
### Phase 3: Detect Issues
|
||||
|
||||
Identify the following problem patterns:
|
||||
|
||||
- **Bloated agent descriptions** — description >30 words in frontmatter loads into every Task tool invocation
|
||||
- **Heavy agents** — files >200 lines inflate Task tool context on every spawn
|
||||
- **Redundant components** — skills that duplicate agent logic, rules that duplicate CLAUDE.md
|
||||
- **MCP over-subscription** — >10 servers, or servers wrapping CLI tools available for free
|
||||
- **CLAUDE.md bloat** — verbose explanations, outdated sections, instructions that should be rules
|
||||
|
||||
### Phase 4: Report
|
||||
|
||||
Produce the context budget report:
|
||||
|
||||
```
|
||||
Context Budget Report
|
||||
═══════════════════════════════════════
|
||||
|
||||
Total estimated overhead: ~XX,XXX tokens
|
||||
Context model: Claude Sonnet (200K window)
|
||||
Effective available context: ~XXX,XXX tokens (XX%)
|
||||
|
||||
Component Breakdown:
|
||||
┌─────────────────┬────────┬───────────┐
|
||||
│ Component │ Count │ Tokens │
|
||||
├─────────────────┼────────┼───────────┤
|
||||
│ Agents │ N │ ~X,XXX │
|
||||
│ Skills │ N │ ~X,XXX │
|
||||
│ Rules │ N │ ~X,XXX │
|
||||
│ MCP tools │ N │ ~XX,XXX │
|
||||
│ CLAUDE.md │ N │ ~X,XXX │
|
||||
└─────────────────┴────────┴───────────┘
|
||||
|
||||
WARNING: Issues Found (N):
|
||||
[ranked by token savings]
|
||||
|
||||
Top 3 Optimizations:
|
||||
1. [action] → save ~X,XXX tokens
|
||||
2. [action] → save ~X,XXX tokens
|
||||
3. [action] → save ~X,XXX tokens
|
||||
|
||||
Potential savings: ~XX,XXX tokens (XX% of current overhead)
|
||||
```
|
||||
|
||||
In verbose mode, additionally output per-file token counts, line-by-line breakdown of the heaviest files, specific redundant lines between overlapping components, and MCP tool list with per-tool schema size estimates.
|
||||
|
||||
## Examples
|
||||
|
||||
**Basic audit**
|
||||
```
|
||||
User: /context-budget
|
||||
Skill: Scans setup → 16 agents (12,400 tokens), 28 skills (6,200), 87 MCP tools (43,500), 2 CLAUDE.md (1,200)
|
||||
Flags: 3 heavy agents, 14 MCP servers (3 CLI-replaceable)
|
||||
Top saving: remove 3 MCP servers → -27,500 tokens (47% overhead reduction)
|
||||
```
|
||||
|
||||
**Verbose mode**
|
||||
```
|
||||
User: /context-budget --verbose
|
||||
Skill: Full report + per-file breakdown showing planner.md (213 lines, 1,840 tokens),
|
||||
MCP tool list with per-tool sizes, duplicated rule lines side by side
|
||||
```
|
||||
|
||||
**Pre-expansion check**
|
||||
```
|
||||
User: I want to add 5 more MCP servers, do I have room?
|
||||
Skill: Current overhead 33% → adding 5 servers (~50 tools) would add ~25,000 tokens → pushes to 45% overhead
|
||||
Recommendation: remove 2 CLI-replaceable servers first to stay under 40%
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Token estimation**: use `words × 1.3` for prose, `chars / 4` for code-heavy files
|
||||
- **MCP is the biggest lever**: each tool schema costs ~500 tokens; a 30-tool server costs more than all your skills combined
|
||||
- **Agent descriptions are loaded always**: even if the agent is never invoked, its description field is present in every Task tool context
|
||||
- **Verbose mode for debugging**: use when you need to pinpoint the exact files driving overhead, not for regular audits
|
||||
- **Audit after changes**: run after adding any agent, skill, or MCP server to catch creep early
|
||||
@@ -273,7 +273,7 @@ import json, sys, os, re
|
||||
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
|
||||
_SECRET_RE = re.compile(
|
||||
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
|
||||
r"""(["'"'"'\s:=]{1,8})"""
|
||||
r"""(["'"'"'\s:=\\]{1,8})"""
|
||||
r"((?:bearer|basic|token|bot)\s+)?"
|
||||
r"([A-Za-z0-9_\-/.+=]{8,256})"
|
||||
)
|
||||
@@ -336,14 +336,15 @@ observation = {
|
||||
"project_name": os.environ.get("PROJECT_NAME_ENV", "global")
|
||||
}
|
||||
|
||||
# Scrub secrets: match common key=value, key: value, and key"value patterns
|
||||
# Scrub secrets, including JSON-escaped quotes/backslashes in serialized tool I/O.
|
||||
# This is defense in depth; arbitrary content still requires a metadata-only capture boundary.
|
||||
# Includes optional auth scheme (e.g., "Bearer", "Basic") before token
|
||||
# Linear-time secret matcher. Bounded quantifiers and a fixed set of auth
|
||||
# schemes (instead of a generic [A-Za-z]+\s+ that overlapped the value class)
|
||||
# prevent the catastrophic backtracking that pegged python at 100% CPU (#2278).
|
||||
_SECRET_RE = re.compile(
|
||||
r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)"
|
||||
r"""(["'"'"'\s:=]{1,8})"""
|
||||
r"""(["'"'"'\s:=\\]{1,8})"""
|
||||
r"((?:bearer|basic|token|bot)\s+)?"
|
||||
r"([A-Za-z0-9_\-/.+=]{8,256})"
|
||||
)
|
||||
@@ -569,7 +570,10 @@ fi
|
||||
# which caused runaway parallel Claude analysis processes.
|
||||
SIGNAL_EVERY_N="${ECC_OBSERVER_SIGNAL_EVERY_N:-20}"
|
||||
SIGNAL_COUNTER_FILE="${PROJECT_DIR}/.observer-signal-counter"
|
||||
SIGNAL_COUNTER_LOCK="${SIGNAL_COUNTER_FILE}.lock"
|
||||
# Use a regular-file OS lock. Keep legacy mkdir locks untouched: a killed
|
||||
# Windows process cannot run shell traps, and reclaiming those directories can
|
||||
# race a live owner. The versioned file makes a stranded directory irrelevant.
|
||||
SIGNAL_COUNTER_LOCK="${SIGNAL_COUNTER_FILE}.lock.v2"
|
||||
ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity"
|
||||
|
||||
touch "$ACTIVITY_FILE" 2>/dev/null || true
|
||||
@@ -578,9 +582,7 @@ touch "$ACTIVITY_FILE" 2>/dev/null || true
|
||||
# tool call (which can fire every second), so concurrent invocations previously
|
||||
# raced on this counter: both read the same value, both incremented, and one
|
||||
# write was lost, signaling the observer at unpredictable intervals (#2296).
|
||||
# Prefer flock (a kernel advisory lock the OS releases automatically if the hook
|
||||
# is killed); fall back to the atomic mkdir lock this script already uses for
|
||||
# the lazy-start path above. Both wrap the same read-modify-write below.
|
||||
# Prefer flock; Python supplies the same OS-released lock on macOS and Windows.
|
||||
should_signal=0
|
||||
|
||||
_clv2_bump_signal_counter() {
|
||||
@@ -613,38 +615,61 @@ if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null
|
||||
fi
|
||||
exec 8>&- 2>/dev/null || true
|
||||
else
|
||||
# No flock (e.g. macOS): atomic mkdir lock with a bounded spin so the hook
|
||||
# never blocks indefinitely. A trap releases the lock on every exit path --
|
||||
# including the async-timeout SIGTERM -- so a killed hook does not strand the
|
||||
# directory. We deliberately do NOT hand-roll PID-based stale reclaim:
|
||||
# re-verifying then removing another process's lock is racy and can delete a
|
||||
# live re-acquirer's directory, reintroducing the very race this fixes.
|
||||
_signal_lock_held=0
|
||||
_signal_lock_spins=0
|
||||
while [ "$_signal_lock_spins" -lt 100 ]; do
|
||||
if mkdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null; then
|
||||
# EXIT cleans up on normal completion. INT/TERM must release AND exit:
|
||||
# a signal trap that only released the lock would otherwise fall through
|
||||
# and continue the read-modify-write without ownership.
|
||||
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true' EXIT
|
||||
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 130' INT
|
||||
trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 143' TERM
|
||||
_signal_lock_held=1
|
||||
break
|
||||
fi
|
||||
_signal_lock_spins=$((_signal_lock_spins + 1))
|
||||
sleep 0.02
|
||||
done
|
||||
if [ "$_signal_lock_held" -eq 1 ]; then
|
||||
# Bump only under the held lock -- never an unlocked read-modify-write.
|
||||
_clv2_bump_signal_counter
|
||||
rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true
|
||||
trap - EXIT INT TERM
|
||||
fi
|
||||
# If the lock could not be acquired within the spin budget we skip this tick
|
||||
# rather than racing on an unlocked counter. Dropping one throttle tick under
|
||||
# extreme contention only delays the next observer signal slightly; it never
|
||||
# corrupts the counter or signals spuriously.
|
||||
# One interpreter, monotonic elapsed-time wait, no MSYS2 sleep subprocesses.
|
||||
# Both fcntl and msvcrt release ownership even on SIGKILL/TerminateProcess.
|
||||
# If locking is unavailable or busy, skip the optional tick without an
|
||||
# unlocked update. Observation capture above has already completed.
|
||||
should_signal=$("$PYTHON_CMD" - "$SIGNAL_COUNTER_LOCK" "$SIGNAL_COUNTER_FILE" "$SIGNAL_EVERY_N" <<'PY'
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
# Git Bash passes /c/... paths to native Windows Python unchanged.
|
||||
for index in (1, 2):
|
||||
sys.argv[index] = re.sub(r"^/([A-Za-z])/", r"\1:/", sys.argv[index])
|
||||
def acquire(handle):
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
def acquire(handle):
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
|
||||
with open(sys.argv[1], "a+b") as guard:
|
||||
if os.name == "nt" and os.fstat(guard.fileno()).st_size == 0:
|
||||
guard.write(b"0")
|
||||
guard.flush()
|
||||
deadline = time.monotonic() + 2.0
|
||||
while True:
|
||||
try:
|
||||
acquire(guard)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() >= deadline:
|
||||
print(0)
|
||||
sys.exit(0)
|
||||
time.sleep(0.02)
|
||||
try:
|
||||
with open(sys.argv[2], encoding="ascii") as source:
|
||||
value = source.read().strip()
|
||||
counter = int(value) if value and value.isascii() and value.isdigit() else 0
|
||||
counter += 1
|
||||
signal = int(counter >= int(sys.argv[3]))
|
||||
if signal:
|
||||
counter = 0
|
||||
except FileNotFoundError:
|
||||
counter, signal = 1, 0
|
||||
with open(sys.argv[2], "w", encoding="ascii") as target:
|
||||
target.write(str(counter) + "\n")
|
||||
print(signal)
|
||||
except (ImportError, OSError, ValueError):
|
||||
print(0)
|
||||
PY
|
||||
) || should_signal=0
|
||||
fi
|
||||
|
||||
# Signal observer if running and throttle allows (check both project-scoped and global observer, deduplicate)
|
||||
|
||||
+30
-11
@@ -117,19 +117,23 @@ For **code** targets the gate reads the change it is about to allow (Edit
|
||||
and clock reads on either side; in shell scripts also
|
||||
redirection to a file, `curl`, `wget`, `jq` and PowerShell file and web
|
||||
cmdlets. Without them the data
|
||||
question is dropped.
|
||||
question is dropped. With `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` on, a
|
||||
change that keeps the data question is also asked what outside the
|
||||
repository decides its format or semantics.
|
||||
- **Trivial:** see [Comment and whitespace-only edits](#comment-and-whitespace-only-edits).
|
||||
|
||||
Supported extensions: `.js .mjs .cjs .jsx .ts .tsx .mts .cts .py .pyi .go .rs
|
||||
.java .kt .kts .cs .c .h .cc .cpp .cxx .hpp .hh .hxx .sh .bash .zsh .ps1 .psm1
|
||||
.bat .cmd`. Any other extension, a
|
||||
missing or non-string field, more than 64 MultiEdit entries, a side over
|
||||
64 KiB, or 256 KiB in total means the full questions, word for word. Sensitive
|
||||
64 KiB, or 256 KiB in total means the full questions, word for word: the
|
||||
original four (importers, public API, data schemas, the user's instruction),
|
||||
or five with `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` on. Sensitive
|
||||
and hard-linked targets and NotebookEdit calls always get the full questions. Other classes keep their class
|
||||
questions. Each question has a stable id (`importers`, `public-api`,
|
||||
`local-callers`, `callers`, `no-duplicate`, `data-schema`,
|
||||
`quote-instruction`, and one per class question), listed in
|
||||
`docs/gateguard/change-profile.md`.
|
||||
`external-contract` when opted in, `quote-instruction`, and one per class
|
||||
question), listed in `docs/gateguard/change-profile.md`.
|
||||
|
||||
### Questions by target class
|
||||
|
||||
@@ -179,7 +183,8 @@ Condensed denials (after `GATEGUARD_FACT_FORCE_FULL_DENIALS`) carry a
|
||||
one-line hint for the same class. For code targets the hint names the same
|
||||
questions as the full denial, one short phrase each; an Edit whose change
|
||||
cannot be read keeps the fixed hint (importers/callers, affected API, data
|
||||
schemas if any). Besides the message text, the class only
|
||||
schemas if any) when the external-contract question is off. Opting in adds
|
||||
its external-contract phrase. Besides the message text, the class only
|
||||
decides [sibling collapse](#same-turn-sibling-creation-collapse): which files
|
||||
may collapse, and that a sibling must share the first file's class.
|
||||
|
||||
@@ -368,10 +373,21 @@ An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an
|
||||
the hook cannot see, so such a segment never credits. File-name globs that
|
||||
restrict a search (Grep `glob: '*.py'`, `rg -g '*.py'`, `grep --include`,
|
||||
`find -name`, `tree -P`, `-Include`/`-Filter`) must match the target's file
|
||||
name for its stem to count.
|
||||
name for its stem to count. File types filter the same way: `rg -t`/
|
||||
`--type` and the Grep `type` include the type's globs, `rg -T`/`--type-not`
|
||||
exclude them, and `fd -e`/`--extension` includes `*.<ext>`, so
|
||||
`rg -t py tokenizer src` never credits `tokenizer.js`. An unknown type name,
|
||||
`--type-add`, or `--type-clear` makes the segment never credit.
|
||||
- **Lookups are not searches:** a search confined to the target's own file
|
||||
name — every include glob, the Glob pattern, or every path pattern ends in
|
||||
that exact name (`Glob **/tokenizer.js`, `find -name tokenizer.js`,
|
||||
`rg -g tokenizer.js`, Grep `glob: 'tokenizer.js'`) — could only find the
|
||||
target, so it never credits it. `Glob **/tokenizer.*` still does.
|
||||
- **Scope:** `Glob`, `Grep`, and `LS` only credit a target strictly inside the
|
||||
directory they searched (their `path`, else the tool `cwd`, plus a Glob
|
||||
pattern's literal leading directories). A Glob without glob characters is a
|
||||
pattern's literal leading directories). A Glob pattern is a filter too: it
|
||||
must match the target's path for the stem to count, so `Glob **/tokenizer.py`
|
||||
never credits `tokenizer.js`. A Glob without glob characters is a
|
||||
one-file lookup: it names no directory and credits only files directly in
|
||||
its directory, never the looked-up file itself. A search whose path is the target is not a search.
|
||||
- **Shell segments:** a segment without a path operand counts only when it is
|
||||
@@ -384,7 +400,8 @@ An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an
|
||||
characters, never credit.
|
||||
- **Shell scope:** a shell segment only credits a target equal to or inside
|
||||
one of its path operands (resolved against the tool `cwd`; a glob operand
|
||||
counts as its literal directory), or inside the `cwd` when it has none:
|
||||
must match the target or a directory above it, and a git pathspec's `*`
|
||||
also matches `/`), or inside the `cwd` when it has none:
|
||||
`rg payment docs` or `grep -rn payment /usr/share/doc` never credit
|
||||
`src/payment.py`. An input redirection before any operand
|
||||
(`rg payment < file`) or a `<` inside a word reads stdin and never counts.
|
||||
@@ -583,10 +600,12 @@ load-bearing destructive-Bash checks keep running:
|
||||
| `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. |
|
||||
| `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. |
|
||||
| `GATEGUARD_METRICS` | unset (off) | Records one line per Edit/Write/MultiEdit/NotebookEdit/Bash/PowerShell decision in `<GATEGUARD_STATE_DIR>/metrics.jsonl` (see [Decision metrics](#decision-metrics)). Never changes a decision. |
|
||||
| `GATEGUARD_EXTERNAL_CONTRACT_QUESTION` | unset (off) | Adds a code question after the data-schema question: name what outside the repository decides the format, units, timezone or protocol semantics. Experimental: in hidden-intent trials it did not change outcomes (`docs/gateguard/question-effectiveness.md`). Never changes a decision. |
|
||||
|
||||
`GATEGUARD_BASH_ROUTINE_DISABLED` and `GATEGUARD_METRICS` accept `1`, `true`,
|
||||
`on`, `enabled`, `enable`, or `yes` (case- and whitespace-insensitive); any
|
||||
other value leaves the routine gate on and metrics off.
|
||||
`GATEGUARD_BASH_ROUTINE_DISABLED`, `GATEGUARD_METRICS` and
|
||||
`GATEGUARD_EXTERNAL_CONTRACT_QUESTION` accept `1`, `true`, `on`, `enabled`,
|
||||
`enable`, or `yes` (case- and whitespace-insensitive); any other value leaves
|
||||
the routine gate on, metrics off, and the question unasked.
|
||||
|
||||
#### Decision metrics
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -66,6 +66,7 @@ function runHook(input, env = {}) {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GATEGUARD_EXTERNAL_CONTRACT_QUESTION: '',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
@@ -89,6 +90,7 @@ function runBashHook(input, env = {}) {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GATEGUARD_EXTERNAL_CONTRACT_QUESTION: '',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
@@ -120,6 +122,7 @@ function runPowerShellHook(input, env = {}) {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GATEGUARD_EXTERNAL_CONTRACT_QUESTION: '',
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
@@ -4387,8 +4390,7 @@ function runTests() {
|
||||
'1. Name the file(s) and line(s) that will call this new file',
|
||||
'2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
|
||||
"5. Quote the user's current instruction verbatim",
|
||||
"4. Quote the user's current instruction verbatim",
|
||||
'',
|
||||
'If this call was sent in a parallel batch, other edits to src/a.py from that batch may already have been applied. Re-read the file before building on them.',
|
||||
'',
|
||||
@@ -4405,8 +4407,7 @@ function runTests() {
|
||||
'1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)',
|
||||
'2. List the public functions/classes affected by this change',
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
'4. Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
|
||||
"5. Quote the user's current instruction verbatim",
|
||||
"4. Quote the user's current instruction verbatim",
|
||||
'',
|
||||
'If this call was sent in a parallel batch, other edits to src/b.py from that batch may already have been applied. Re-read the file before building on them.',
|
||||
'',
|
||||
@@ -4468,12 +4469,10 @@ function runTests() {
|
||||
callers: 'Name the file(s) and line(s) that will call this new file',
|
||||
noDuplicate: 'Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash)',
|
||||
dataSchema: 'If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
externalContract:
|
||||
'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained',
|
||||
quote: QUOTE_LINE
|
||||
};
|
||||
const B2_FULL_EDIT = [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote];
|
||||
const B2_FULL_WRITE = [QUESTION_LINES.callers, QUESTION_LINES.noDuplicate, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote];
|
||||
const B2_FULL_EDIT = [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.dataSchema, QUESTION_LINES.quote];
|
||||
const B2_FULL_WRITE = [QUESTION_LINES.callers, QUESTION_LINES.noDuplicate, QUESTION_LINES.dataSchema, QUESTION_LINES.quote];
|
||||
|
||||
clearState();
|
||||
if (
|
||||
@@ -4501,12 +4500,45 @@ function runTests() {
|
||||
if (
|
||||
test('an Edit that handles data keeps the data-schema question', () => {
|
||||
const reason = profileDenialReason('Edit', { file_path: 'src/calc.py', old_string: ' x = 1', new_string: ' rows = json.load(fh)' });
|
||||
assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]);
|
||||
assert.deepStrictEqual(listedQuestions(reason), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.quote]);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
const EXTERNAL_CONTRACT_LINE =
|
||||
'Name what outside this repository decides the format, units, timezone or protocol semantics here (the consumer, the producer, or a stated convention), or state that the choice is unconstrained';
|
||||
for (const value of ['1', 'true', 'ON']) {
|
||||
clearState();
|
||||
if (
|
||||
test(`GATEGUARD_EXTERNAL_CONTRACT_QUESTION=${value} adds the external-contract question beside data-schema`, () => {
|
||||
const env = { GATEGUARD_EXTERNAL_CONTRACT_QUESTION: value };
|
||||
const data = profileDenialReason('Edit', { file_path: 'src/calc.py', old_string: ' x = 1', new_string: ' rows = json.load(fh)' }, env);
|
||||
assert.deepStrictEqual(listedQuestions(data), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, EXTERNAL_CONTRACT_LINE, QUESTION_LINES.quote]);
|
||||
clearState();
|
||||
const unknown = profileDenialReason('Edit', { file_path: 'src/a.rb', old_string: 'a', new_string: 'b' }, env);
|
||||
assert.deepStrictEqual(listedQuestions(unknown), [...B2_FULL_EDIT.slice(0, 3), EXTERNAL_CONTRACT_LINE, QUESTION_LINES.quote]);
|
||||
clearState();
|
||||
const noData = profileDenialReason('Edit', { file_path: 'src/calc.js', old_string: ' return a + 1;', new_string: ' return a * 2;' }, env);
|
||||
assert.deepStrictEqual(listedQuestions(noData), [QUESTION_LINES.localCallers, QUESTION_LINES.quote], 'no data, no external-contract');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
for (const value of ['', '0', 'false', 'off']) {
|
||||
clearState();
|
||||
if (
|
||||
test(`GATEGUARD_EXTERNAL_CONTRACT_QUESTION=${JSON.stringify(value)} keeps the original four code questions`, () => {
|
||||
const reason = profileDenialReason('Edit', { file_path: 'src/a.rb', old_string: 'a', new_string: 'b' }, { GATEGUARD_EXTERNAL_CONTRACT_QUESTION: value });
|
||||
assert.deepStrictEqual(listedQuestions(reason), B2_FULL_EDIT);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('a Write of a new code file without data handling drops the data-schema question', () => {
|
||||
@@ -4608,7 +4640,7 @@ function runTests() {
|
||||
assert.deepStrictEqual(listedQuestions(exported), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]);
|
||||
clearState();
|
||||
const fetch = profileDenialReason('Edit', { file_path: 'scripts/q4.sh', old_string: ' run', new_string: ' curl -s "$URL" > out.json' });
|
||||
assert.deepStrictEqual(listedQuestions(fetch), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.externalContract, QUESTION_LINES.quote]);
|
||||
assert.deepStrictEqual(listedQuestions(fetch), [QUESTION_LINES.localCallers, QUESTION_LINES.dataSchema, QUESTION_LINES.quote]);
|
||||
clearState();
|
||||
const ps = profileDenialReason('Edit', { file_path: 'scripts/q5.ps1', old_string: 'function Get-A {', new_string: 'function Get-B {' });
|
||||
assert.deepStrictEqual(listedQuestions(ps), [QUESTION_LINES.importers, QUESTION_LINES.publicApi, QUESTION_LINES.quote]);
|
||||
@@ -5669,14 +5701,25 @@ function runTests() {
|
||||
['Grep widget glob *.{py,js} (braces are one glob)', 'Grep', { pattern: 'widget', glob: '*.{py,js}', path: creditRoot }],
|
||||
['Grep widget glob !node_modules/**', 'Grep', { pattern: 'widget', glob: '!node_modules/**', path: creditRoot }],
|
||||
['grep --exclude-dir=node_modules widget', 'Bash', { command: 'grep -r --exclude-dir=node_modules widget .' }],
|
||||
['find -name widget.py', 'Bash', { command: 'find . -name widget.py' }],
|
||||
['Grep TODO glob widget.py (positive glob names the stem)', 'Grep', { pattern: 'TODO', glob: 'widget.py', path: creditRoot }]
|
||||
["find -name 'widget.*'", 'Bash', { command: "find . -name 'widget.*'" }],
|
||||
['Grep TODO glob widget.* (positive glob names the stem)', 'Grep', { pattern: 'TODO', glob: 'widget.*', path: creditRoot }]
|
||||
];
|
||||
for (const [label, name, input] of exclusionCredited) {
|
||||
gateCase(`control still credits (${label})`, () => {
|
||||
assertCredited(creditEdit(exclusionTarget, exclusionTurn(name, input)), label);
|
||||
});
|
||||
}
|
||||
// A search confined to the target's own file name read the target rather than searched for it.
|
||||
const lookupDenied = [
|
||||
['find -name widget.py', 'Bash', { command: 'find . -name widget.py' }],
|
||||
['Grep TODO glob widget.py', 'Grep', { pattern: 'TODO', glob: 'widget.py', path: creditRoot }]
|
||||
];
|
||||
for (const [label, name, input] of lookupDenied) {
|
||||
gateCase(`a lookup of the target by its own name never credits it (${label})`, () => {
|
||||
assertNotCredited(creditEdit(exclusionTarget, exclusionTurn(name, input)), label);
|
||||
assert.ok(!readState().fact_force_credited, `${label}: nothing credited`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Bracket classes in filter globs match exactly ---
|
||||
const bracketCases = [
|
||||
@@ -5780,10 +5823,10 @@ function runTests() {
|
||||
});
|
||||
}
|
||||
const psCredited = [
|
||||
'Get-ChildItem -Path src -Recurse -Filter widget.py',
|
||||
'Get-ChildItem -Recurse -Filt:widget.py',
|
||||
'Get-ChildItem -Path src -Recurse -Filter widget.*',
|
||||
'Get-ChildItem -Recurse -Filt:widget.*',
|
||||
'Get-ChildItem -Recurse -Include a.md, widget.py',
|
||||
'Get-ChildItem -Recurse -Exclude a.md, b.md -Filter widget.py',
|
||||
'Get-ChildItem -Recurse -Exclude a.md, b.md -Filter widget.*',
|
||||
'Select-String -Pattern widget -Path src/* -Exclude a.md'
|
||||
];
|
||||
for (const command of psCredited) {
|
||||
@@ -5791,6 +5834,11 @@ function runTests() {
|
||||
assertCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command);
|
||||
});
|
||||
}
|
||||
for (const command of ['Get-ChildItem -Path src -Recurse -Filter widget.py', 'Get-ChildItem -Recurse -Filt:widget.py']) {
|
||||
gateCase(`a PowerShell lookup of the target by its own name never credits it (${command})`, () => {
|
||||
assertNotCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command);
|
||||
});
|
||||
}
|
||||
gateCase('PowerShell include lists spread across tokens still filter the target', () => {
|
||||
const command = 'Get-ChildItem -Recurse -Include a.md, widget.md';
|
||||
assertNotCredited(creditEdit(exclusionTarget, exclusionTurn('PowerShell', { command })), command);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '../..');
|
||||
const runner = path.join(repoRoot, 'scripts/hooks/run-with-flags.js');
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try { fn(); console.log(` PASS ${name}`); passed += 1; }
|
||||
catch (error) { console.log(` FAIL ${name}: ${error.message}`); failed += 1; }
|
||||
}
|
||||
|
||||
function capture(phase, input) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observe-redaction-'));
|
||||
try {
|
||||
const data = path.join(root, 'data');
|
||||
const result = spawnSync(process.execPath, [runner, `${phase}:observe`,
|
||||
'scripts/hooks/observe-runner.js', 'standard,strict'], {
|
||||
input: JSON.stringify({ cwd: root, session_id: 'redaction-fixture', ...input }),
|
||||
cwd: root, encoding: 'utf8', timeout: 12000,
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root,
|
||||
CLV2_HOMUNCULUS_DIR: data, CLAUDE_CODE_ENTRYPOINT: 'cli',
|
||||
ECC_HOOK_PROFILE: 'standard', ECC_SKIP_OBSERVE: '0',
|
||||
ECC_DISABLED_HOOKS: '', ECC_OBSERVER_ENABLED: 'false' }
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.doesNotMatch(result.stderr, /runner failed|timed out/i);
|
||||
const records = fs.readFileSync(path.join(data, 'observations.jsonl'), 'utf8');
|
||||
assert.equal(records.trim().split('\n').length, 1);
|
||||
assert.equal(JSON.parse(records).event, phase === 'pre' ? 'tool_start' : 'tool_complete');
|
||||
return records;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Actual registered observation secret redaction ===');
|
||||
if (process.platform !== 'win32' && spawnSync('python3', ['--version']).status === 0) {
|
||||
for (const [label, content] of [
|
||||
['double-quoted source', 'const apiKey = "CANARY_TOKEN_abcdefghijk";'],
|
||||
['single-quoted source', "const password = 'CANARY_TOKEN_abcdefghijk';"],
|
||||
['JSON credential', '{"token":"CANARY_TOKEN_abcdefghijk"}'],
|
||||
['nested escaped source', 'const auth = \\"CANARY_TOKEN_abcdefghijk\\";']
|
||||
]) {
|
||||
test(`redacts ${label} before persisting tool input`, () => {
|
||||
const records = capture('pre', { tool_name: 'Write',
|
||||
tool_input: { file_path: '/tmp/example.js', content } });
|
||||
assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted');
|
||||
assert.ok(records.includes('[REDACTED]'));
|
||||
});
|
||||
test(`redacts ${label} before persisting structured tool output`, () => {
|
||||
const records = capture('post', { tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/example.js' }, tool_response: { content } });
|
||||
assert.ok(!records.includes('CANARY_TOKEN_abcdefghijk'), 'seeded credential persisted');
|
||||
assert.ok(records.includes('[REDACTED]'));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping shell fixtures: native Windows runner requires platform CI.');
|
||||
}
|
||||
console.log(`Passed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exitCode = failed ? 1 : 0;
|
||||
@@ -6,8 +6,8 @@
|
||||
* every N observations (#521). The bump used a plain read-modify-write with no
|
||||
* locking, so concurrent hook invocations could read the same value, both
|
||||
* increment, and lose a write — the observer then fired at unpredictable
|
||||
* intervals. The fix serializes the read-modify-write with an atomic mkdir
|
||||
* lock.
|
||||
* intervals. The fix serializes the read-modify-write with an OS-released
|
||||
* file lock.
|
||||
*
|
||||
* These tests drive the real observe.sh (reusing the stub harness from
|
||||
* observer-memory.test.js) and assert the lock's invariant: with the reset
|
||||
@@ -68,9 +68,8 @@ const observeShPath = path.join(repoRoot, 'skills', 'continuous-learning-v2', 'h
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const hasPython = !isWindows && spawnSync('python3', ['--version']).status === 0;
|
||||
// When the runner has flock the lock is exact (blocking, kernel auto-release);
|
||||
// without it observe.sh uses a best-effort mkdir spin that may drop at most one
|
||||
// increment under pathological contention.
|
||||
// Both lock implementations wait for at most two seconds, then drop an optional
|
||||
// tick under pathological contention.
|
||||
const hasFlock = !isWindows && spawnSync('bash', ['-c', 'command -v flock']).status === 0;
|
||||
|
||||
// Build a self-contained observe.sh sandbox (stub detect-project.sh +
|
||||
@@ -129,7 +128,7 @@ function buildSandbox() {
|
||||
}
|
||||
|
||||
// Run observe.sh once against the sandbox. Resolves when the process exits.
|
||||
function runObserve(testObserve, projectDir) {
|
||||
function runObserve(testObserve, projectDir, extraEnv = {}) {
|
||||
const input = JSON.stringify({
|
||||
tool_name: 'Read',
|
||||
tool_input: { file_path: '/tmp/test.txt' },
|
||||
@@ -147,7 +146,8 @@ function runObserve(testObserve, projectDir) {
|
||||
CLAUDE_PROJECT_DIR: projectDir,
|
||||
// Reset threshold far above the invocation count, so no reset fires and
|
||||
// the final counter equals the number of invocations.
|
||||
ECC_OBSERVER_SIGNAL_EVERY_N: '100000'
|
||||
ECC_OBSERVER_SIGNAL_EVERY_N: '100000',
|
||||
...extraEnv
|
||||
},
|
||||
stdio: ['pipe', 'ignore', 'pipe']
|
||||
});
|
||||
@@ -185,18 +185,6 @@ function readCounter(projectDir) {
|
||||
|
||||
console.log('\n=== observe.sh signal-counter race regression (#2296) ===\n');
|
||||
|
||||
test('observe.sh uses a lock around the throttle-counter update', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('SIGNAL_COUNTER_LOCK'),
|
||||
'observe.sh should define a lock for the signal counter'
|
||||
);
|
||||
assert.ok(
|
||||
/flock 8\b/.test(content) || /mkdir "\$SIGNAL_COUNTER_LOCK"/.test(content),
|
||||
'observe.sh should acquire the counter lock via flock or an atomic mkdir'
|
||||
);
|
||||
});
|
||||
|
||||
test('observe.sh guards against a corrupt (non-integer) counter file', () => {
|
||||
const content = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(
|
||||
@@ -241,12 +229,12 @@ async function runConcurrent() {
|
||||
`with flock the counter must be exactly ${K}, got ${counter}`
|
||||
);
|
||||
} else {
|
||||
// mkdir fallback is best-effort: it may drop at most one increment if its
|
||||
// The portable fallback may drop at most one increment if its
|
||||
// bounded spin is exhausted, but never the multi-increment loss the
|
||||
// unlocked code exhibited.
|
||||
assert.ok(
|
||||
counter >= K - 1,
|
||||
`mkdir fallback should keep the counter >= ${K - 1}, got ${counter}`
|
||||
`portable fallback should keep the counter >= ${K - 1}, got ${counter}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -254,10 +242,69 @@ async function runConcurrent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runStrandedLock() {
|
||||
const { testDir, projectDir, testObserve } = buildSandbox();
|
||||
try {
|
||||
// Windows TerminateProcess cannot run the shell's cleanup traps. Preserve
|
||||
// that exact leftover, and make each external sleep as slow as MSYS2.
|
||||
const stale = path.join(projectDir, '.observer-signal-counter.lock');
|
||||
fs.mkdirSync(stale);
|
||||
fs.writeFileSync(path.join(projectDir, '.observer-signal-counter'), '7\n');
|
||||
const bashEnv = path.join(testDir, 'no-flock.sh');
|
||||
fs.writeFileSync(bashEnv, [
|
||||
'command() {',
|
||||
' if [ "$1" = "-v" ] && { [ "$2" = "flock" ] || [ "$2" = "lockfile" ]; }; then return 1; fi',
|
||||
' builtin command "$@"',
|
||||
'}',
|
||||
'sleep() { /bin/sleep 0.08; }',
|
||||
''
|
||||
].join('\n'));
|
||||
const started = Date.now();
|
||||
await runObserve(testObserve, projectDir, { BASH_ENV: bashEnv });
|
||||
assert.ok(Date.now() - started < 3000, 'stale lock must not consume the runner timeout');
|
||||
assert.strictEqual(readCounter(projectDir), 8, 'a leftover legacy directory must not freeze signalling');
|
||||
assert.ok(fs.statSync(stale).isDirectory(), 'legacy lock must be left untouched');
|
||||
} finally {
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKilledOwner() {
|
||||
const { testDir, projectDir, testObserve } = buildSandbox();
|
||||
let owner;
|
||||
try {
|
||||
fs.writeFileSync(path.join(projectDir, '.observer-signal-counter'), '7\n');
|
||||
const guard = path.join(projectDir, '.observer-signal-counter.lock.v2');
|
||||
const bashEnv = path.join(testDir, 'no-flock.sh');
|
||||
fs.writeFileSync(bashEnv, 'command() { if [ "$1" = "-v" ] && [ "$2" = "flock" ]; then return 1; fi; builtin command "$@"; }\n');
|
||||
owner = spawn('python3', ['-u', '-c',
|
||||
'import fcntl,sys,time\nf=open(sys.argv[1], "a+b")\nfcntl.flock(f,fcntl.LOCK_EX)\nprint("locked",flush=True)\ntime.sleep(30)', guard]);
|
||||
await new Promise((resolve, reject) => {
|
||||
owner.once('error', reject);
|
||||
owner.stdout.once('data', resolve);
|
||||
});
|
||||
const started = Date.now();
|
||||
await runObserve(testObserve, projectDir, { BASH_ENV: bashEnv });
|
||||
assert.ok(Date.now() - started < 4000, 'busy OS lock must stay within the runner budget');
|
||||
assert.strictEqual(readCounter(projectDir), 7, 'busy lock must not allow an unlocked update');
|
||||
const closed = new Promise(resolve => owner.once('close', resolve));
|
||||
owner.kill('SIGKILL');
|
||||
await closed;
|
||||
await runObserve(testObserve, projectDir, { BASH_ENV: bashEnv });
|
||||
assert.strictEqual(readCounter(projectDir), 8, 'OS must release a killed owner without cleanup traps');
|
||||
assert.ok(fs.statSync(guard).isFile(), 'persistent guard bytes must not retain lock ownership');
|
||||
} finally {
|
||||
if (owner) owner.kill('SIGKILL');
|
||||
cleanupDir(testDir);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (!isWindows && hasPython) {
|
||||
await asyncTest('sequential invocations increment the counter exactly once each', runSequential);
|
||||
await asyncTest('concurrent invocations never lose a counter increment', runConcurrent);
|
||||
await asyncTest('stranded mkdir lock cannot delay or freeze later observations', runStrandedLock);
|
||||
await asyncTest('busy lock skips safely and a killed owner releases it automatically', runKilledOwner);
|
||||
} else {
|
||||
console.log(' - skipping shell-execution tests (requires non-Windows + python3)');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
createShadowTrail, appendAcknowledgement, appendScopeRevision,
|
||||
appendReobservation, verifyShadowTrail, summarizeShadowTrail
|
||||
} = require('../../scripts/lib/agent-proximity/shadow-trail');
|
||||
|
||||
const at = '2026-09-15T12:00:00.000Z';
|
||||
const later = '2026-09-15T12:01:00.000Z';
|
||||
const latest = '2026-09-15T12:02:00.000Z';
|
||||
const baseRevision = 'a'.repeat(40);
|
||||
|
||||
function observation(agentId, overrides = {}) {
|
||||
return {
|
||||
agentId, taskId: `task-${agentId}`, repoId: 'ecc', baseRevision,
|
||||
scopeRevision: 1, footprint: 'complete', readPaths: [],
|
||||
writePaths: ['src/shared.js'], observedAt: '2026-09-15T11:59:00.000Z',
|
||||
expiresAt: '2026-09-15T12:05:00.000Z', ...overrides
|
||||
};
|
||||
}
|
||||
function start(a = observation('a'), b = observation('b'), options = {}) {
|
||||
return createShadowTrail({ trailId: 'shadow-1', observations: [a, b], at, ...options });
|
||||
}
|
||||
function bothAcknowledge(trail) {
|
||||
const first = appendAcknowledgement(trail, { id: 'ack-a', agentId: 'a', scopeRevision: 1, at: later });
|
||||
return appendAcknowledgement(first, { id: 'ack-b', agentId: 'b', scopeRevision: 1, at: later });
|
||||
}
|
||||
|
||||
test('records a copy-on-append passive observation → advisory → ACK → scope revision → re-observation chain', () => {
|
||||
const initial = start();
|
||||
assert.equal(summarizeShadowTrail(initial).latestAdvisory.state, 'advisory');
|
||||
assert.equal(summarizeShadowTrail(initial).latestAdvisory.hazard, 'write-write');
|
||||
const acknowledged = bothAcknowledge(initial);
|
||||
assert.deepEqual(summarizeShadowTrail(acknowledged).latestAdvisoryAcknowledgedBy, ['a', 'b']);
|
||||
const revised = appendScopeRevision(acknowledged, {
|
||||
id: 'scope-b2', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'declared-contract-42', at: later
|
||||
});
|
||||
const complete = appendReobservation(revised, {
|
||||
id: 'reobserve-1', at: latest,
|
||||
observations: [observation('a', { writePaths: ['src/shared.js'], observedAt: later }),
|
||||
observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })]
|
||||
});
|
||||
const summary = summarizeShadowTrail(complete);
|
||||
assert.equal(summary.mode, 'shadow-read-only');
|
||||
assert.equal(summary.latestAdvisory.state, 'no-advisory-in-scope');
|
||||
assert.equal(summary.outcome, 'unadjudicated');
|
||||
assert.equal(summary.scopeChange.authority, 'declared-only');
|
||||
assert.deepEqual(summary.historicalAcknowledgedBy, ['a', 'b']);
|
||||
assert.deepEqual(summary.latestAdvisoryAcknowledgedBy, []);
|
||||
assert.equal(verifyShadowTrail(JSON.parse(JSON.stringify(complete))), true);
|
||||
assert.equal(initial.receipts.length, 2);
|
||||
assert.equal(acknowledged.receipts.length, 4);
|
||||
assert.equal(complete.receipts.length, 7);
|
||||
for (let i = 1; i < complete.receipts.length; i += 1) {
|
||||
assert.equal(complete.receipts[i].previousHash, complete.receipts[i - 1].hash);
|
||||
}
|
||||
assert.ok(!JSON.stringify(complete).includes('CANARY_SECRET'));
|
||||
});
|
||||
|
||||
test('read/read is quiet, write/read is a direct hazard, and dependency proximity reuses shipped scorer', () => {
|
||||
const quiet = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }),
|
||||
observation('b', { readPaths: ['src/shared.js'], writePaths: [] }));
|
||||
assert.equal(summarizeShadowTrail(quiet).latestAdvisory.state, 'no-advisory-in-scope');
|
||||
const hazard = start(observation('a', { readPaths: ['src/shared.js'], writePaths: [] }),
|
||||
observation('b', { readPaths: [], writePaths: ['src/shared.js'] }));
|
||||
assert.equal(summarizeShadowTrail(hazard).latestAdvisory.hazard, 'write-read');
|
||||
const graph = { adjacency: { 'apps/web/page.js': ['packages/core/util.js'], 'packages/core/util.js': [] } };
|
||||
const coupled = start(observation('a', { writePaths: ['apps/web/page.js'] }),
|
||||
observation('b', { writePaths: ['packages/core/util.js'] }), { graph });
|
||||
assert.equal(summarizeShadowTrail(coupled).latestAdvisory.state, 'advisory');
|
||||
assert.equal(summarizeShadowTrail(coupled).latestAdvisory.hazard, null);
|
||||
});
|
||||
|
||||
test('known overlap cannot be clear, while incomplete, stale and incomparable critical data remain unknown', () => {
|
||||
const partialHazard = start(observation('a', { footprint: 'incomplete' }), observation('b'));
|
||||
assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.state, 'unknown');
|
||||
assert.equal(summarizeShadowTrail(partialHazard).latestAdvisory.hazard, 'write-write');
|
||||
const partialQuiet = start(observation('a', { footprint: 'incomplete', writePaths: [] }),
|
||||
observation('b', { writePaths: ['docs/guide.md'] }));
|
||||
assert.equal(summarizeShadowTrail(partialQuiet).latestAdvisory.state, 'unknown');
|
||||
const stale = start(observation('a', { expiresAt: '2026-09-15T11:59:30.000Z' }), observation('b'));
|
||||
assert.equal(summarizeShadowTrail(stale).latestAdvisory.state, 'unknown');
|
||||
const foreignBase = start(observation('a'), observation('b', { baseRevision: 'b'.repeat(40) }));
|
||||
assert.equal(summarizeShadowTrail(foreignBase).latestAdvisory.state, 'unknown');
|
||||
});
|
||||
|
||||
test('stale or crossed ACK, unacknowledged scope change and mismatched re-observation fail closed', () => {
|
||||
const initial = start();
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'a', scopeRevision: 2, at: later }), /Invalid shadow/);
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: 'bad-ack', agentId: 'c', scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
assert.throws(() => appendScopeRevision(initial, { id: 'premature', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'evidence-1', at: later }), /Invalid shadow/);
|
||||
const ack = bothAcknowledge(initial);
|
||||
assert.throws(() => appendAcknowledgement(ack, { id: 'third-ack', agentId: 'a', scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
const revised = appendScopeRevision(ack, { id: 'scope-b2', agentId: 'b', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'evidence-1', at: later });
|
||||
assert.throws(() => appendReobservation(revised, { id: 'wrong-scope', at: latest,
|
||||
observations: [observation('a'), observation('b')] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'foreign-task', at: latest,
|
||||
observations: [observation('a'), observation('b', { scopeRevision: 2, taskId: 'other-task' })] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'different-declaration', at: latest,
|
||||
observations: [observation('a'), observation('b', { scopeRevision: 2,
|
||||
writePaths: ['src/not-declared.js'], observedAt: later })] }), /Invalid shadow/);
|
||||
assert.throws(() => appendReobservation(revised, { id: 'changed-other-agent', at: latest,
|
||||
observations: [observation('a', { writePaths: ['src/changed-without-revision.js'], observedAt: later }),
|
||||
observation('b', { scopeRevision: 2, writePaths: ['docs/guide.md'], observedAt: later })] }), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('unknown critical footprints cannot be used as an acknowledged clear-scoped resolution', () => {
|
||||
const unknown = start(observation('a', { footprint: 'incomplete', writePaths: [] }), observation('b'));
|
||||
assert.throws(() => appendAcknowledgement(unknown, { id: 'ack-unknown', agentId: 'a',
|
||||
scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('receipt hashes bind the trail identity and reject cross-trail relabeling', () => {
|
||||
const relabeled = JSON.parse(JSON.stringify(start()));
|
||||
relabeled.trailId = 'different-shadow-trail';
|
||||
assert.throws(() => verifyShadowTrail(relabeled), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('a persisted receipt with reordered path bytes is noncanonical and rejected', () => {
|
||||
const trail = start(observation('a', { writePaths: ['src/a.js', 'src/b.js'] }),
|
||||
observation('b', { writePaths: ['src/b.js'] }));
|
||||
const reordered = JSON.parse(JSON.stringify(trail));
|
||||
reordered.receipts[0].payload.observations[0].writePaths.reverse();
|
||||
assert.throws(() => verifyShadowTrail(reordered), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('dependency graph insertion order canonicalizes to the same receipt content', () => {
|
||||
const a = start(observation('a'), observation('b'), { graph: { adjacency: {
|
||||
'src/z.js': ['src/a.js'], 'src/a.js': []
|
||||
} } });
|
||||
const b = start(observation('a'), observation('b'), { graph: { adjacency: {
|
||||
'src/a.js': [], 'src/z.js': ['src/a.js']
|
||||
} } });
|
||||
assert.equal(a.receipts[0].hash, b.receipts[0].hash);
|
||||
});
|
||||
|
||||
test('append results keep independent copies of earlier receipt payloads', () => {
|
||||
const initial = start();
|
||||
const appended = appendAcknowledgement(initial, {
|
||||
id: 'copy-ack', agentId: 'a', scopeRevision: 1, at: later
|
||||
});
|
||||
initial.receipts[0].payload.observations[0].writePaths[0] = 'src/changed.js';
|
||||
assert.throws(() => verifyShadowTrail(initial), /Invalid shadow/);
|
||||
assert.equal(verifyShadowTrail(appended), true);
|
||||
});
|
||||
|
||||
test('verification rejects nested serialization hooks, sparse and accessor arrays before execution', () => {
|
||||
const original = start();
|
||||
let callbackRuns = 0;
|
||||
const hooked = JSON.parse(JSON.stringify(original));
|
||||
hooked.receipts[0].payload.observations[0].writePaths.toJSON = () => {
|
||||
callbackRuns += 1;
|
||||
return ['src/shared.js'];
|
||||
};
|
||||
assert.throws(() => verifyShadowTrail(hooked), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
|
||||
const accessor = JSON.parse(JSON.stringify(original));
|
||||
Object.defineProperty(accessor.receipts[0].payload.observations, '0', {
|
||||
enumerable: true, get() { callbackRuns += 1; return observation('a'); }
|
||||
});
|
||||
assert.throws(() => verifyShadowTrail(accessor), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
|
||||
const sparse = JSON.parse(JSON.stringify(original));
|
||||
delete sparse.receipts[0].payload.observations[0].writePaths[0];
|
||||
assert.throws(() => verifyShadowTrail(sparse), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('hash fields reject coercible objects without invoking their conversion hooks', () => {
|
||||
const trail = JSON.parse(JSON.stringify(start()));
|
||||
let callbackRuns = 0;
|
||||
trail.receipts[0].hash = { toString() { callbackRuns += 1; return '0'.repeat(64); } };
|
||||
assert.throws(() => verifyShadowTrail(trail), /Invalid shadow/);
|
||||
assert.equal(callbackRuns, 0);
|
||||
});
|
||||
|
||||
test('receipt verification never invokes inherited JSON serialization hooks', () => {
|
||||
const persisted = JSON.parse(JSON.stringify(start()));
|
||||
let callbackRuns = 0;
|
||||
Object.defineProperty(Array.prototype, 'toJSON', { configurable: true,
|
||||
value() { callbackRuns += 1; return []; } });
|
||||
try {
|
||||
assert.equal(verifyShadowTrail(persisted), true);
|
||||
assert.equal(callbackRuns, 0);
|
||||
} finally {
|
||||
delete Array.prototype.toJSON;
|
||||
}
|
||||
});
|
||||
|
||||
test('exotic verification failures are generic and do not echo private text', () => {
|
||||
const trail = start();
|
||||
const receipts = new Proxy(trail.receipts, {
|
||||
getPrototypeOf() { throw new Error('CANARY_SECRET'); }
|
||||
});
|
||||
assert.throws(() => verifyShadowTrail({ ...trail, receipts }), error =>
|
||||
/Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET'));
|
||||
});
|
||||
|
||||
test('scope revision requires both acknowledged observations to remain current', () => {
|
||||
const initial = start(observation('a'), observation('b', {
|
||||
expiresAt: '2026-09-15T12:01:30.000Z'
|
||||
}));
|
||||
const acknowledged = bothAcknowledge(initial);
|
||||
assert.throws(() => appendScopeRevision(acknowledged, {
|
||||
id: 'scope-after-peer-expiry', agentId: 'a', previousScopeRevision: 1,
|
||||
newScopeRevision: 2, newReadPaths: [], newWritePaths: ['docs/guide.md'],
|
||||
authorizationRef: 'declared-contract-42', at: latest
|
||||
}), /Invalid shadow/);
|
||||
});
|
||||
|
||||
test('tampering, duplicate IDs, unknown fields and unbounded input cannot be accepted or echoed', () => {
|
||||
const initial = start();
|
||||
const tampered = JSON.parse(JSON.stringify(initial));
|
||||
tampered.receipts[0].payload.observations[0].writePaths = ['CANARY_SECRET'];
|
||||
assert.throws(() => verifyShadowTrail(tampered), /Invalid shadow/);
|
||||
const poisoned = observation('a', { summary: 'CANARY_SECRET' });
|
||||
assert.throws(() => start(poisoned, observation('b')), error =>
|
||||
/Invalid shadow/.test(error.message) && !error.message.includes('CANARY_SECRET'));
|
||||
assert.throws(() => start(observation('a', { writePaths: ['../escape'] }), observation('b')), /Invalid shadow/);
|
||||
assert.throws(() => start(observation('a', { writePaths: Array(33).fill('src/a.js') }), observation('b')), /Invalid shadow/);
|
||||
assert.throws(() => start(observation('a'), observation('b'), { graph: { adjacency: { '__proto__': ['src/a.js'] } } }), /Invalid shadow/);
|
||||
assert.throws(() => appendAcknowledgement(initial, { id: initial.receipts[0].id, agentId: 'a',
|
||||
scopeRevision: 1, at: later }), /Invalid shadow/);
|
||||
});
|
||||
@@ -35,6 +35,16 @@ test('a comment-only change to C-family code is trivial', () => {
|
||||
assert.strictEqual(isTrivialEdit('a(); // x', 'a(); // y', JS), true);
|
||||
});
|
||||
|
||||
test('a Unicode line terminator makes an edit non-trivial', () => {
|
||||
// JavaScript ends a line comment at U+2028 and U+2029, and C# also at U+0085.
|
||||
for (const sep of ['\u2028', '\u2029', '\u0085']) {
|
||||
const code = sep.codePointAt(0).toString(16);
|
||||
assert.strictEqual(isTrivialEdit(`// note${sep}foo(1);`, `// note${sep}foo(2);`, JS), false, `U+${code} ends the comment`);
|
||||
assert.strictEqual(isTrivialEdit(`a(); // x${sep}y`, `a(); // z${sep}y`, JS), false, `U+${code} anywhere`);
|
||||
assert.strictEqual(isTrivialEdit(`x = 1 # a${sep}b`, `x = 1 # c${sep}b`, PY), false, `U+${code} in any language`);
|
||||
}
|
||||
});
|
||||
|
||||
test('comment markers inside a string are code', () => {
|
||||
assert.strictEqual(isTrivialEdit('const s = "a // b";', 'const s = "a // c";', JS), false);
|
||||
assert.strictEqual(isTrivialEdit("const s = 'x /* y */ z';", "const s = 'x /* q */ z';", JS), false);
|
||||
|
||||
@@ -157,6 +157,80 @@ test('stripped test stems keep the generic, length, word-boundary and scope rule
|
||||
assert.strictEqual(miss && miss.reason, 'generic-stem');
|
||||
});
|
||||
|
||||
const filterScan = searches => ({ turnId: null, searches, batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] });
|
||||
const filterSearch = (name, input) => ({ name, input, callsAgo: 1, messageId: 'msg_1' });
|
||||
const label = ([name, input]) => `${name} ${JSON.stringify(input)}`;
|
||||
|
||||
test('a type, extension or path pattern that misses the target stops its stem from crediting', () => {
|
||||
const { findCreditingSearch, findClosestMiss } = loadHook();
|
||||
const data = { cwd: root };
|
||||
const target = `${root}/src/parser/tokenizer.js`;
|
||||
const missing = [
|
||||
['Bash', { command: 'rg -t py tokenizer src' }],
|
||||
['Bash', { command: 'rg -T js tokenizer src' }],
|
||||
['Bash', { command: 'rg --type-not js tokenizer src' }],
|
||||
['Bash', { command: 'fd -e py tokenizer src' }],
|
||||
['Grep', { pattern: 'tokenizer', type: 'py' }],
|
||||
['Glob', { pattern: '**/tokenizer.py' }],
|
||||
['Bash', { command: 'rg tokenizer src/*.py' }],
|
||||
['Bash', { command: 'grep -rn tokenizer src/**/*.py' }],
|
||||
['Bash', { command: "git grep tokenizer -- '*.py'" }],
|
||||
['Bash', { command: "git ls-files '*tokenizer.py'" }]
|
||||
];
|
||||
for (const search of missing) {
|
||||
const scan = filterScan([filterSearch(...search)]);
|
||||
assert.strictEqual(findCreditingSearch(scan, target, false, data), null, label(search));
|
||||
assert.strictEqual((findClosestMiss(scan, target, false, data) || {}).reason, 'excluded', label(search));
|
||||
}
|
||||
const covering = [
|
||||
['Bash', { command: 'rg -t js tokenizer src' }],
|
||||
['Bash', { command: 'fd -e js tokenizer src' }],
|
||||
['Grep', { pattern: 'tokenizer', type: 'js' }],
|
||||
['Glob', { pattern: '**/tokenizer*' }],
|
||||
['Glob', { pattern: 'src/**/*tokenizer*' }],
|
||||
['Bash', { command: 'rg tokenizer src/*' }],
|
||||
['Bash', { command: 'rg tokenizer lib/*.py src' }],
|
||||
['Bash', { command: "git grep tokenizer -- '*.js'" }],
|
||||
['Bash', { command: "git grep tokenizer -- 'src/*.js'" }]
|
||||
];
|
||||
for (const search of covering) {
|
||||
assert.ok(findCreditingSearch(filterScan([filterSearch(...search)]), target, false, data), label(search));
|
||||
}
|
||||
});
|
||||
|
||||
test('a search confined to the target file name is a lookup, not a search', () => {
|
||||
const { findCreditingSearch, findClosestMiss } = loadHook();
|
||||
const data = { cwd: root };
|
||||
const target = `${root}/src/parser/tokenizer.js`;
|
||||
const lookups = [
|
||||
['Glob', { pattern: '**/tokenizer.js' }],
|
||||
['Glob', { pattern: 'src/**/tokenizer.js' }],
|
||||
['Grep', { pattern: 'tokenizer', glob: 'tokenizer.js' }],
|
||||
['Grep', { pattern: 'tokenizer', glob: '**/tokenizer.js' }],
|
||||
['Bash', { command: 'find src -name tokenizer.js' }],
|
||||
['Bash', { command: 'rg -g tokenizer.js tokenizer src' }],
|
||||
['Bash', { command: 'rg --files -g tokenizer.js src' }],
|
||||
['Bash', { command: 'rg tokenizer src/**/tokenizer.js' }]
|
||||
];
|
||||
for (const search of lookups) {
|
||||
const scan = filterScan([filterSearch(...search)]);
|
||||
assert.strictEqual(findCreditingSearch(scan, target, false, data), null, label(search));
|
||||
assert.strictEqual((findClosestMiss(scan, target, false, data) || {}).reason, 'lookup', label(search));
|
||||
}
|
||||
const searches = [
|
||||
['Glob', { pattern: '**/tokenizer.*' }],
|
||||
['Bash', { command: "find src -name 'tokenizer.*'" }],
|
||||
['Bash', { command: "rg -g tokenizer.js -g '*.ts' tokenizer src" }]
|
||||
];
|
||||
for (const search of searches) {
|
||||
assert.ok(findCreditingSearch(filterScan([filterSearch(...search)]), target, false, data), label(search));
|
||||
}
|
||||
const testTarget = `${root}/tests/parser/tokenizer.test.js`;
|
||||
const lookupOfModule = filterScan([filterSearch('Glob', { pattern: '**/tokenizer.js' })]);
|
||||
assert.strictEqual(findCreditingSearch(lookupOfModule, testTarget, false, data), null, 'a Glob of the module never listed its test');
|
||||
assert.strictEqual((findClosestMiss(lookupOfModule, testTarget, false, data) || {}).reason, 'excluded');
|
||||
});
|
||||
|
||||
fs.rmSync(transcriptDir, { recursive: true, force: true });
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
|
||||
@@ -86,5 +86,45 @@ test('PowerShell -Exclude binds from an unambiguous prefix', () => {
|
||||
assert.deepStrictEqual(filter.globs, ['a.py', 'widget.py']);
|
||||
});
|
||||
|
||||
const admitted = (item, rel) => filtersAdmitTarget(item, ctxFor(rel), null) && admits(item, rel);
|
||||
|
||||
test('rg -t/--type selects ripgrep type globs instead of being skipped', () => {
|
||||
for (const args of [['-t', 'py'], ['-tpy'], ['--type', 'py'], ['--type=py'], ['-it', 'py']]) {
|
||||
const item = rgItem([...args, 'tokenizer', 'src']);
|
||||
assert.strictEqual(admitted(item, 'src/tokenizer.js'), false, args.join(' '));
|
||||
assert.strictEqual(admitted(item, 'src/tokenizer.pyi'), true, args.join(' '));
|
||||
}
|
||||
assert.strictEqual(admitted(rgItem(['-t', 'js', 'x']), 'src/tokenizer.mjs'), true);
|
||||
assert.strictEqual(admitted(rgItem(['-t', 'py', '-t', 'js', 'x']), 'src/tokenizer.js'), true, 'types are alternatives');
|
||||
});
|
||||
|
||||
test('rg -T/--type-not excludes the type globs', () => {
|
||||
for (const args of [['-T', 'js'], ['-Tjs'], ['--type-not', 'js'], ['--type-not=js']]) {
|
||||
assert.strictEqual(admitted(rgItem([...args, 'x']), 'src/tokenizer.js'), false, args.join(' '));
|
||||
assert.strictEqual(admitted(rgItem([...args, 'x']), 'src/tokenizer.py'), true, args.join(' '));
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown or redefined rg types are unreadable filters', () => {
|
||||
for (const args of [['-t', 'nosuchtype'], ['-T', 'nosuchtype'], ['--type-add', 'web:*.js', '-t', 'web'], ['--type-clear', 'js']]) {
|
||||
assert.strictEqual(admitted(rgItem([...args, 'x']), 'src/tokenizer.js'), false, args.join(' '));
|
||||
}
|
||||
});
|
||||
|
||||
test('fd -e/--extension is an include', () => {
|
||||
const fdItem = args => ({ ...shellSearchFilters('fd', args), scopes: [['.']], pathIncludes: false });
|
||||
for (const args of [['-e', 'py'], ['-epy'], ['--extension', 'py'], ['--extension=.py']]) {
|
||||
assert.strictEqual(admitted(fdItem([...args, 'tokenizer', 'src']), 'src/tokenizer.js'), false, args.join(' '));
|
||||
assert.strictEqual(admitted(fdItem([...args, 'tokenizer', 'src']), 'src/tokenizer.py'), true, args.join(' '));
|
||||
}
|
||||
});
|
||||
|
||||
test('the Grep tool type parameter is an include, and an unknown type is unreadable', () => {
|
||||
const grepItem = (glob, type) => ({ ...grepToolFilters(glob, type), scopes: [['.']], pathIncludes: true });
|
||||
assert.strictEqual(admitted(grepItem(undefined, 'py'), 'src/tokenizer.js'), false);
|
||||
assert.strictEqual(admitted(grepItem(undefined, 'py'), 'src/tokenizer.py'), true);
|
||||
assert.strictEqual(admitted(grepItem(undefined, 'nosuchtype'), 'src/tokenizer.js'), false);
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -347,21 +347,21 @@ test('every non-code class has questions and a condensed hint; code has neither'
|
||||
test('questionIdsFor gives stable ids per class, action and change profile', () => {
|
||||
const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false });
|
||||
const table = [
|
||||
['code', false, null, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', true, null, ['callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, { known: false, touchesPublicSurface: false, touchesData: false }, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, known(true, true), ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, null, ['importers', 'public-api', 'data-schema', 'quote-instruction']],
|
||||
['code', true, null, ['callers', 'no-duplicate', 'data-schema', 'quote-instruction']],
|
||||
['code', false, { known: false, touchesPublicSurface: false, touchesData: false }, ['importers', 'public-api', 'data-schema', 'quote-instruction']],
|
||||
['code', false, known(true, true), ['importers', 'public-api', 'data-schema', 'quote-instruction']],
|
||||
['code', false, known(true, false), ['importers', 'public-api', 'quote-instruction']],
|
||||
['code', false, known(false, true), ['local-callers', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, known(false, true), ['local-callers', 'data-schema', 'quote-instruction']],
|
||||
['code', false, known(false, false), ['local-callers', 'quote-instruction']],
|
||||
['code', true, known(false, false), ['callers', 'no-duplicate', 'quote-instruction']],
|
||||
['code', true, known(true, true), ['callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', true, known(true, true), ['callers', 'no-duplicate', 'data-schema', 'quote-instruction']],
|
||||
['instruction', false, known(false, false), ['loader', 'behaviour-change', 'no-duplicate-instruction', 'quote-instruction']],
|
||||
['test', true, known(false, false), ['under-test', 'existing-tests', 'quote-instruction']],
|
||||
['prose', true, null, ['supersedes', 'linked-from', 'why-new-file', 'quote-instruction']],
|
||||
['prose', false, null, ['references', 'corrects-or-adds', 'quote-instruction']],
|
||||
['config', false, known(false, false), ['config-reader', 'config-effect', 'no-plaintext-secrets', 'quote-instruction']],
|
||||
['__proto__', false, null, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']]
|
||||
['__proto__', false, null, ['importers', 'public-api', 'data-schema', 'quote-instruction']]
|
||||
];
|
||||
for (const [cls, isWrite, profile, expected] of table) {
|
||||
const ids = questionIdsFor(cls, isWrite, profile);
|
||||
@@ -386,7 +386,7 @@ test('condensedHintFor matches the question set', () => {
|
||||
|
||||
test('code condensed hints name exactly the questions of the change profile', () => {
|
||||
const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false });
|
||||
const ids = ['importers', 'public-api', 'local-callers', 'callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction'];
|
||||
const ids = ['importers', 'public-api', 'local-callers', 'callers', 'no-duplicate', 'data-schema', 'quote-instruction'];
|
||||
for (const id of ids) assert.ok(condensedQuestionPhrase(id), `${id} has a phrase`);
|
||||
for (const isWrite of [false, true]) {
|
||||
for (const [s, d] of [[true, true], [true, false], [false, true], [false, false]]) {
|
||||
@@ -427,7 +427,7 @@ test('code condensed hints read as one sentence', () => {
|
||||
);
|
||||
assert.strictEqual(
|
||||
condensedHintFor('code', false, known(true, true)),
|
||||
"briefly state the files that import this file, the public functions/classes affected, the data schemas it reads or writes, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry."
|
||||
"briefly state the files that import this file, the public functions/classes affected, the data schemas it reads or writes, and the user's verbatim instruction, then retry."
|
||||
);
|
||||
assert.strictEqual(
|
||||
condensedHintFor('code', true, known(true, false)),
|
||||
@@ -445,11 +445,11 @@ test('code condensed hints keep the data and duplicate checks when the profile a
|
||||
}
|
||||
});
|
||||
|
||||
test('code condensed Edit hint for an unknown profile names every question asked', () => {
|
||||
const expected = "briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry.";
|
||||
test('code condensed Edit hint for an unknown profile is unchanged', () => {
|
||||
const legacy = "briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry.";
|
||||
for (const profile of [null, undefined, { known: false, touchesPublicSurface: false, touchesData: false }, 'x']) {
|
||||
assert.strictEqual(condensedHintFor('code', false, profile), expected);
|
||||
assert.strictEqual(condensedHintFor('__proto__', false, profile), expected);
|
||||
assert.strictEqual(condensedHintFor('code', false, profile), legacy);
|
||||
assert.strictEqual(condensedHintFor('__proto__', false, profile), legacy);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -457,13 +457,37 @@ test('code condensed Write hint for an unknown profile asks every creation quest
|
||||
for (const profile of [null, undefined, { known: false, touchesPublicSurface: false, touchesData: false }, 'x']) {
|
||||
assert.strictEqual(
|
||||
condensedHintFor('code', true, profile),
|
||||
"briefly state the file(s) and line(s) that will call it, that no existing file serves the same purpose, the data schemas it reads or writes, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry."
|
||||
"briefly state the file(s) and line(s) that will call it, that no existing file serves the same purpose, the data schemas it reads or writes, and the user's verbatim instruction, then retry."
|
||||
);
|
||||
}
|
||||
assert.strictEqual(condensedQuestionPhrase('constructor'), '');
|
||||
assert.strictEqual(condensedQuestionPhrase('loader'), '');
|
||||
});
|
||||
|
||||
test('external-contract joins data-schema only when opted in', () => {
|
||||
const known = (touchesPublicSurface, touchesData) => ({ known: true, language: 'js', touchesPublicSurface, touchesData, trivial: false });
|
||||
const on = { externalContract: true };
|
||||
const table = [
|
||||
['code', false, null, ['importers', 'public-api', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', true, null, ['callers', 'no-duplicate', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, known(false, true), ['local-callers', 'data-schema', 'external-contract', 'quote-instruction']],
|
||||
['code', false, known(true, false), ['importers', 'public-api', 'quote-instruction']],
|
||||
['config', false, known(false, true), ['config-reader', 'config-effect', 'no-plaintext-secrets', 'quote-instruction']]
|
||||
];
|
||||
for (const [cls, isWrite, profile, expected] of table) {
|
||||
assert.deepStrictEqual(questionIdsFor(cls, isWrite, profile, on), expected, `${cls} ${isWrite} ${JSON.stringify(profile)}`);
|
||||
}
|
||||
for (const off of [undefined, {}, { externalContract: false }, { externalContract: 'yes' }]) {
|
||||
assert.ok(!questionIdsFor('code', false, null, off).includes('external-contract'), JSON.stringify(off));
|
||||
}
|
||||
assert.strictEqual(
|
||||
condensedHintFor('code', false, null, on),
|
||||
"briefly state importers/callers, affected API, data schemas if any, what outside the repository fixes the format or semantics, and the user's verbatim instruction, then retry."
|
||||
);
|
||||
assert.ok(condensedHintFor('code', true, known(true, true), on).includes(condensedQuestionPhrase('external-contract')));
|
||||
assert.ok(!condensedHintFor('code', true, known(true, true)).includes(condensedQuestionPhrase('external-contract')));
|
||||
});
|
||||
|
||||
// ── isSensitiveTarget ──
|
||||
|
||||
test('isSensitiveTarget matches every listed basename, segment and prefix rule', () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const skillPath = path.join(repoRoot, 'skills', 'context-budget', 'SKILL.md');
|
||||
const legacyShimPath = path.join(repoRoot, 'legacy-command-shims', 'commands', 'context-budget.md');
|
||||
|
||||
const skill = fs.readFileSync(skillPath, 'utf8');
|
||||
const legacyShim = fs.readFileSync(legacyShimPath, 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Context budget skill tests ===\n');
|
||||
|
||||
test('describes a cross-harness live-context router instead of a Claude-only audit', () => {
|
||||
const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/);
|
||||
assert.ok(frontmatter, 'SKILL.md frontmatter is missing');
|
||||
assert.match(frontmatter[1], /description:.*live context/i);
|
||||
assert.match(frontmatter[1], /Codex/i);
|
||||
assert.doesNotMatch(frontmatter[1], /Audits Claude Code/i);
|
||||
});
|
||||
|
||||
test('routes Codex live context requests to native status surfaces without tools', () => {
|
||||
assert.match(skill, /Codex[\s\S]*`\/status`/i);
|
||||
assert.match(skill, /Codex[\s\S]*`\/statusline`/i);
|
||||
assert.match(skill, /do not (?:call|use).*tools/i);
|
||||
assert.match(skill, /do not (?:scan|read).*files/i);
|
||||
});
|
||||
|
||||
test('routes Claude Code live context requests to its native context command', () => {
|
||||
assert.match(skill, /Claude Code[\s\S]*`\/context`/i);
|
||||
});
|
||||
|
||||
test('requires explicit audit intent before inventorying installed components', () => {
|
||||
assert.match(skill, /only.*(?:`--audit`|explicit).*audit/is);
|
||||
assert.match(skill, /never infer.*audit/is);
|
||||
assert.match(skill, /bounded/i);
|
||||
});
|
||||
|
||||
test('keeps estimated static overhead distinct from live provider usage', () => {
|
||||
assert.match(skill, /estimate/i);
|
||||
assert.match(skill, /not.*live (?:session|context)/i);
|
||||
assert.match(skill, /do not claim/i);
|
||||
});
|
||||
|
||||
test('keeps the legacy slash shim on the same safe default', () => {
|
||||
assert.match(legacyShim, /native live-context command/i);
|
||||
assert.match(legacyShim, /explicit.*`--audit`/i);
|
||||
assert.doesNotMatch(legacyShim, /Assume a 200K context window/i);
|
||||
});
|
||||
|
||||
console.log(`\nPassed: ${passed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in new issue
Block a user