@ngockhoale/ukit 3.4.68 → 3.4.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/manifests/documentation.yaml +88 -0
- package/manifests/platform.full.yaml +13 -1
- package/package.json +5 -1
- package/scripts/install/module-dependency-closure.mjs +53 -0
- package/scripts/install/sync-installed-mirror.mjs +17 -4
- package/scripts/release/candidate-state.mjs +31 -0
- package/scripts/release/verify-release.mjs +60 -6
- package/src/core/buildPlan.js +2 -0
- package/src/core/executionContracts.js +1 -309
- package/template_project/.claude/commands/ukit/handoff-create.md +41 -1
- package/template_project/.claude/hooks/handoff-model-guard.sh +459 -1
- package/template_project/.claude/hooks/skill-router.sh +585 -357
- package/template_project/.claude/ukit/index/publication-intent.mjs +528 -0
- package/template_project/.claude/ukit/index/route-resolver.mjs +1 -290
- package/template_project/.claude/ukit/index/route-task.mjs +13 -1
- package/template_project/.claude/ukit/runtime/async-lock.mjs +3 -3
- package/template_project/.claude/ukit/runtime/block-breaker.mjs +232 -1
- package/template_project/.claude/ukit/runtime/cron-operation-adapter.mjs +46 -8
- package/template_project/.claude/ukit/runtime/execution-ledger.mjs +161 -56
- package/template_project/.claude/ukit/runtime/hook-chain-runner.mjs +51 -1
- package/template_project/.claude/ukit/runtime/operation-evidence.mjs +34 -10
- package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +357 -44
- package/template_project/.omp/hooks/pre/ukit-bridge.js +50 -26
- package/scripts/dev/capture-omp-stall.mjs +0 -246
|
@@ -563,13 +563,458 @@ function patchTargets(input) {
|
|
|
563
563
|
return { paths, unparseable };
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
// ---- Owner-authorized docs-only git publication (create scope) ------------------
|
|
567
|
+
// The shell-write rule blocks every git commit/push in a planning session. The ONE
|
|
568
|
+
// exception: an explicit OWNER message in the same session authorizes a single,
|
|
569
|
+
// strictly parsed `git commit`/`git push` whose staged files (commit) or every
|
|
570
|
+
// outgoing commit's files (push) are docs-scope. Trust rules:
|
|
571
|
+
// - Only real user turns of the three known host shapes count (mirrors
|
|
572
|
+
// handoff-intent.mjs structure rules). Assistant prose, tool_result round-trips
|
|
573
|
+
// and the command template itself ("NO git commit. NO git push.") never authorize.
|
|
574
|
+
// - The LATEST qualifying owner text decides (a later revocation wins; a later
|
|
575
|
+
// re-grant is honored). Text evidence only RELAXES an already-proven create
|
|
576
|
+
// scope — it never mints ownership, and no TTL marker file participates.
|
|
577
|
+
// Per-op, affirmative, docs-explicit grants. "publish" is deliberately NOT a
|
|
578
|
+
// grant word (ambiguous with npm publish). A grant must name BOTH the exact
|
|
579
|
+
// operation (commit vs push — a commit grant never authorizes a push) AND the
|
|
580
|
+
// docs scope. The newest DECISIVE owner text per op wins: an explicit negative
|
|
581
|
+
// ("do not commit") revokes; questions ("why did push fail?"), history
|
|
582
|
+
// ("the push failed yesterday") and wholly quoted text are NOT orders — they
|
|
583
|
+
// neither grant nor revoke, and the scan continues to older turns.
|
|
584
|
+
// NOTE: JS \b is ASCII-only, so Vietnamese words like "đẩy"/"tạo"/"đừng" can
|
|
585
|
+
// NEVER match \b...\b (đ is not an ASCII word char). Explicit lookarounds keep
|
|
586
|
+
// the same "standalone word" semantics for every script.
|
|
587
|
+
const ownerWord = (core) => new RegExp(`(?<![A-Za-z0-9_])(?:${core})(?![A-Za-z0-9_])`, 'iu');
|
|
588
|
+
const OWNER_PUB_OP = {
|
|
589
|
+
commit: ownerWord('commit'),
|
|
590
|
+
push: ownerWord('push|đẩy'),
|
|
591
|
+
};
|
|
592
|
+
const OWNER_NEG = "(?:no|not|don'?t|do\\s+not|never|without|không|đừng|chưa)";
|
|
593
|
+
const OWNER_PUB_VETO_OP = {
|
|
594
|
+
commit: new RegExp(`(?<![A-Za-z0-9_])${OWNER_NEG}(?![A-Za-z0-9_])[^.\\n]{0,30}?(?<![A-Za-z0-9_])commit(?![A-Za-z0-9_])`, 'iu'),
|
|
595
|
+
push: new RegExp(`(?<![A-Za-z0-9_])${OWNER_NEG}(?![A-Za-z0-9_])[^.\\n]{0,30}?(?<![A-Za-z0-9_])(?:push|đẩy)(?![A-Za-z0-9_])`, 'iu'),
|
|
596
|
+
};
|
|
597
|
+
const OWNER_DOCS_SCOPE = /(?<![A-Za-z0-9_])(?:docs|documentation|tài\s+liệu)(?![A-Za-z0-9_])/iu;
|
|
598
|
+
const OWNER_PUB_CREATE = /(?<![A-Za-z0-9_])(?:create|new|tạo)(?![A-Za-z0-9_])/iu;
|
|
599
|
+
// A grant must be an AFFIRMATIVE ORDER in command position: after optional
|
|
600
|
+
// politeness/filler lead words, a line must OPEN with a publication/create
|
|
601
|
+
// imperative. Merely mentioning the op ("the docs push is pending",
|
|
602
|
+
// "docs commit status", "I mentioned push in docs") is not an order — absence
|
|
603
|
+
// of a negative is never proof of an order.
|
|
604
|
+
const OWNER_LEAD = '(?:(?:please|hãy|vui\\s+lòng|làm\\s+ơn|ok|okay|now|go\\s+ahead(?:\\s+and)?|giúp(?:\\s+tôi)?|then|next|sau\\s+đó|luôn)[,.!]?\\s*)*';
|
|
605
|
+
const OWNER_ORDER_LINE = new RegExp(`^${OWNER_LEAD}(?:commit|push|đẩy|create|tạo)(?=[\\s.,;:!]|$)`, 'iu');
|
|
606
|
+
function opOrderedOnLine(text, op) {
|
|
607
|
+
for (const line of String(text).split('\n')) {
|
|
608
|
+
if (OWNER_ORDER_LINE.test(line) && OWNER_PUB_OP[op].test(line)) return true;
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
const OWNER_PUB_NON_ORDER = /\?|\b(?:why|how|when|what|where|who|which|did|didn|doesn|was|were|had|has|should|could|would|can|failed|failing|failure|yesterday|earlier|previously|remember)\b/iu;
|
|
613
|
+
// Same bound as the shared session-intent scan window (32 MiB): a bounded tail
|
|
614
|
+
// read, never a whole-file read, and only when a git publication is attempted.
|
|
615
|
+
const OWNER_TAIL_BYTES = 32 * 1024 * 1024;
|
|
616
|
+
|
|
617
|
+
function whollyQuoted(text) {
|
|
618
|
+
const t = String(text).trim();
|
|
619
|
+
if (t.length < 2) return false;
|
|
620
|
+
const pairs = { '"': '"', "'": "'", '`': '`', '“': '”', '‘': '’', '«': '»' };
|
|
621
|
+
return Boolean(pairs[t[0]]) && pairs[t[0]] === t[t.length - 1];
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Bounded scan: read only the last OWNER_TAIL_BYTES of the transcript — the
|
|
625
|
+
// same 32 MiB bound as the shared session-intent window — via a positioned
|
|
626
|
+
// read; the whole file is never loaded, and the scan runs only when a git
|
|
627
|
+
// publication is attempted.
|
|
628
|
+
async function readOwnerTail(p) {
|
|
629
|
+
try {
|
|
630
|
+
const fd = await fsp.open(p, 'r');
|
|
631
|
+
try {
|
|
632
|
+
const st = await fd.stat();
|
|
633
|
+
if (!st.isFile() || st.size === 0) return '';
|
|
634
|
+
const len = Math.min(st.size, OWNER_TAIL_BYTES);
|
|
635
|
+
const buf = Buffer.alloc(len);
|
|
636
|
+
const { bytesRead } = await fd.read(buf, 0, len, st.size - len);
|
|
637
|
+
let s = buf.subarray(0, bytesRead).toString('utf8');
|
|
638
|
+
if (len < st.size) { const nl = s.indexOf('\n'); if (nl < 0) return ''; s = s.slice(nl + 1); }
|
|
639
|
+
return s;
|
|
640
|
+
} finally { await fd.close(); }
|
|
641
|
+
} catch { return ''; }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function commandTemplateBlock(text) {
|
|
645
|
+
if (/<command-name>|<command-args>/.test(text)) return true;
|
|
646
|
+
const firstLine = String(text).split('\n').find((l) => l.trim()) || '';
|
|
647
|
+
return /^\s{0,3}#{1,3}\s+\/?(?:ukit:)?handoff-(?:create|fullstack|implement|review|status|clear)(?![\w-])/.test(firstLine);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Text blocks of a user turn (owner words), or null when the entry is not a user
|
|
651
|
+
// turn of a known host shape. Tool-output blocks and command-template turns are
|
|
652
|
+
// dropped: they are never the owner's own words.
|
|
653
|
+
function ownerUserBlocks(entry) {
|
|
654
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
655
|
+
// Session bind + sidechain exclusion: a grant is only valid from THIS session's
|
|
656
|
+
// own top-level user turns. Subagent sidechains and foreign-session entries are
|
|
657
|
+
// never owner words, even when they carry a matching payload shape.
|
|
658
|
+
if (entry.isSidechain === true || String(entry.isSidechain) === 'true') return null;
|
|
659
|
+
const sid = entry.sessionId ?? entry.session_id;
|
|
660
|
+
if (sid !== undefined && sid !== null && String(sid) !== '') {
|
|
661
|
+
const cur = payload?.session_id ?? payload?.sessionId;
|
|
662
|
+
if (cur !== undefined && cur !== null && String(cur) !== '' && String(sid) !== String(cur)) return null;
|
|
663
|
+
}
|
|
664
|
+
let content = null;
|
|
665
|
+
if (entry.type === 'user' && entry.message?.role === 'user') content = entry.message.content;
|
|
666
|
+
else if (entry.type === 'message' && entry.message?.role === 'user') content = entry.message.content;
|
|
667
|
+
else if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload?.role === 'user') content = entry.payload.content;
|
|
668
|
+
else return null;
|
|
669
|
+
if (typeof content === 'string') return commandTemplateBlock(content) ? [] : [content];
|
|
670
|
+
if (!Array.isArray(content)) return [];
|
|
671
|
+
const blocks = [];
|
|
672
|
+
for (const b of content) {
|
|
673
|
+
if (!b || typeof b !== 'object') continue;
|
|
674
|
+
if (b.type === 'tool_result' || b.type === 'toolResult' || b.type === 'tool-result') continue;
|
|
675
|
+
if ((b.type === 'text' || b.type === 'input_text') && typeof b.text === 'string' && !commandTemplateBlock(b.text)) blocks.push(b.text);
|
|
676
|
+
}
|
|
677
|
+
return blocks;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// -> { commit: {ok, creation}, push: {ok, creation} } — per-op, newest decisive
|
|
681
|
+
// owner text decides independently for each operation.
|
|
682
|
+
let ownerPubMemo = null;
|
|
683
|
+
async function ownerGitPublishAuthorized() {
|
|
684
|
+
if (ownerPubMemo !== null) return ownerPubMemo;
|
|
685
|
+
const res = {
|
|
686
|
+
commit: { ok: false, creation: false },
|
|
687
|
+
push: { ok: false, creation: false },
|
|
688
|
+
};
|
|
689
|
+
const transcriptPath = typeof payload?.transcript_path === 'string' ? payload.transcript_path : '';
|
|
690
|
+
if (transcriptPath) {
|
|
691
|
+
const lines = (await readOwnerTail(transcriptPath)).split('\n');
|
|
692
|
+
const decided = { commit: false, push: false };
|
|
693
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
694
|
+
let entry;
|
|
695
|
+
try { entry = JSON.parse(lines[i]); } catch { continue; }
|
|
696
|
+
const blocks = ownerUserBlocks(entry);
|
|
697
|
+
if (blocks === null) continue;
|
|
698
|
+
for (let bi = blocks.length - 1; bi >= 0; bi -= 1) {
|
|
699
|
+
const b = blocks[bi];
|
|
700
|
+
if (whollyQuoted(b)) continue; // quoted text is never an order (nor a revocation)
|
|
701
|
+
for (const op of ['commit', 'push']) {
|
|
702
|
+
if (decided[op] || !OWNER_PUB_OP[op].test(b)) continue;
|
|
703
|
+
if (OWNER_PUB_VETO_OP[op].test(b)) { decided[op] = true; continue; } // explicit revocation stays denied
|
|
704
|
+
if (!opOrderedOnLine(b, op)) continue; // mention/status/report: not an affirmative order
|
|
705
|
+
if (OWNER_PUB_NON_ORDER.test(b)) continue; // question/history wording: not an order, keep scanning
|
|
706
|
+
if (!OWNER_DOCS_SCOPE.test(b)) continue; // the order must name the docs scope
|
|
707
|
+
decided[op] = true;
|
|
708
|
+
res[op] = { ok: true, creation: OWNER_PUB_CREATE.test(b) };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (decided.commit && decided.push) break;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
ownerPubMemo = res;
|
|
715
|
+
return ownerPubMemo;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Strict tokenizer for the exception command: plain argv words only, single- and
|
|
719
|
+
// double-quoted literals allowed; any redirect/meta/expansion/glob character
|
|
720
|
+
// (`; | & < > ( ) $ \` * ? [ ] { } # ~ !`) is outside the grammar and returns null.
|
|
721
|
+
function tokenizeStrict(text) {
|
|
722
|
+
const tokens = [];
|
|
723
|
+
let cur = '';
|
|
724
|
+
let has = false;
|
|
725
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
726
|
+
const ch = text[i];
|
|
727
|
+
if (ch === "'") {
|
|
728
|
+
const end = text.indexOf("'", i + 1);
|
|
729
|
+
if (end === -1) return null;
|
|
730
|
+
cur += text.slice(i + 1, end);
|
|
731
|
+
has = true;
|
|
732
|
+
i = end;
|
|
733
|
+
} else if (ch === '"') {
|
|
734
|
+
let j = i + 1;
|
|
735
|
+
for (;;) {
|
|
736
|
+
if (j >= text.length) return null;
|
|
737
|
+
if (text[j] === '\\' && (text[j + 1] === '"' || text[j + 1] === '\\')) { cur += text[j + 1]; j += 2; continue; }
|
|
738
|
+
if (text[j] === '"') break;
|
|
739
|
+
if (text[j] === '$' || text[j] === '`') return null;
|
|
740
|
+
cur += text[j];
|
|
741
|
+
j += 1;
|
|
742
|
+
}
|
|
743
|
+
has = true;
|
|
744
|
+
i = j;
|
|
745
|
+
} else if (ch === '\\') {
|
|
746
|
+
if (i + 1 >= text.length) return null;
|
|
747
|
+
cur += text[i + 1];
|
|
748
|
+
has = true;
|
|
749
|
+
i += 1;
|
|
750
|
+
} else if (/\s/.test(ch)) {
|
|
751
|
+
if (has) { tokens.push(cur); cur = ''; has = false; }
|
|
752
|
+
} else if (';|&<>()$`*?[]{}#~!'.includes(ch)) {
|
|
753
|
+
return null;
|
|
754
|
+
} else {
|
|
755
|
+
cur += ch;
|
|
756
|
+
has = true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
if (has) tokens.push(cur);
|
|
760
|
+
return tokens;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// -> { op: 'commit' } | { op: 'push', positionals } | null for anything else.
|
|
764
|
+
// Strict: NO git global options at all (`-C`, `-c`, `--git-dir`, `--work-tree`,
|
|
765
|
+
// `--exec`-class redirects cannot be verified, so they are denied, not sanitized),
|
|
766
|
+
// a fixed option allowlist per subcommand, and plain-ref positionals only.
|
|
767
|
+
function parseGitPublication(seg) {
|
|
768
|
+
if (!seg || seg.sep || seg.heredoc) return null;
|
|
769
|
+
const tokens = tokenizeStrict(seg.text);
|
|
770
|
+
if (!tokens || tokens.length < 2 || tokens[0].replace(/^.*\//, '') !== 'git') return null;
|
|
771
|
+
const sub = tokens[1];
|
|
772
|
+
const rest = tokens.slice(2);
|
|
773
|
+
if (sub === 'commit') {
|
|
774
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
775
|
+
const t = rest[i];
|
|
776
|
+
if (t === '-m' || t === '--message') { i += 1; if (i >= rest.length) return null; continue; }
|
|
777
|
+
if (t.startsWith('--message=')) continue;
|
|
778
|
+
if (/^-m[^-]/.test(t) && t.length > 2) continue;
|
|
779
|
+
if (t === '--allow-empty' || t === '-q' || t === '--quiet') continue;
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
return { op: 'commit' };
|
|
783
|
+
}
|
|
784
|
+
if (sub !== 'push') return null;
|
|
785
|
+
const benign = new Set(['-q', '--quiet', '-v', '--verbose', '--dry-run', '-u', '--set-upstream', '--progress']);
|
|
786
|
+
const positionals = [];
|
|
787
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
788
|
+
const t = rest[i];
|
|
789
|
+
if (benign.has(t)) continue;
|
|
790
|
+
if (t.startsWith('-')) return null; // force/mirror/all/tags/delete/… stay denied
|
|
791
|
+
// Plain ref names only: a `src:dst` refspec redirects the destination and is
|
|
792
|
+
// never verified here; `:ref` deletes.
|
|
793
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(t)) return null;
|
|
794
|
+
positionals.push(t);
|
|
795
|
+
if (positionals.length > 2) return null;
|
|
796
|
+
}
|
|
797
|
+
return { op: 'push', positionals };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function gitRead(...args) {
|
|
801
|
+
try {
|
|
802
|
+
const { execFileSync } = require('child_process');
|
|
803
|
+
return execFileSync('git', ['-C', projectRoot, ...args], {
|
|
804
|
+
encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 4 * 1024 * 1024,
|
|
805
|
+
});
|
|
806
|
+
} catch { return null; }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// docs-only means the 'allow-doc' class exactly: a staged/outgoing test file is
|
|
810
|
+
// still outside the publication scope (create sessions write docs; tests ship
|
|
811
|
+
// with the implementation cycle).
|
|
812
|
+
function firstOutOfScopePath(names) {
|
|
813
|
+
for (const n of names) {
|
|
814
|
+
if (String(n).length && classifyScopePath(String(n).replace(/\\/g, '/')) !== 'allow-doc') return n;
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Regular-file docs only: a symlink (120000) or submodule/gitlink (160000) under
|
|
820
|
+
// docs/ is a non-blueprint payload even though its path looks like docs.
|
|
821
|
+
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
|
|
822
|
+
function nonRegularDocsPayload(entries) {
|
|
823
|
+
for (const e of entries) {
|
|
824
|
+
if (!REGULAR_FILE_MODES.has(e.mode)) return e.path;
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// `git diff --cached --raw -z --no-renames` => ":<srcmode> <dstmode> <sha> <sha> <status>\0<path>\0"
|
|
830
|
+
// --no-renames is mandatory: rename detection would show only the NEW path and
|
|
831
|
+
// hide a source→docs move whose old path is source.
|
|
832
|
+
function parseRawZ(out) {
|
|
833
|
+
const parts = String(out).split('\0');
|
|
834
|
+
const entries = [];
|
|
835
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
836
|
+
const meta = parts[i];
|
|
837
|
+
if (!meta) continue;
|
|
838
|
+
const m = /^:\d+ (\d{6}) [0-9a-f]+ [0-9a-f]+ ([A-Z])/.exec(meta);
|
|
839
|
+
if (!m) return null;
|
|
840
|
+
const p = parts[i + 1];
|
|
841
|
+
i += 1;
|
|
842
|
+
if (p === undefined || p === '') return null;
|
|
843
|
+
entries.push({ mode: m[1], status: m[2], path: p });
|
|
844
|
+
}
|
|
845
|
+
return entries;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function stagedDocsDenial() {
|
|
849
|
+
const out = gitRead('diff', '--cached', '--raw', '-z', '--no-renames');
|
|
850
|
+
if (out === null) return 'the staged file set could not be read (git diff --cached failed)';
|
|
851
|
+
const entries = parseRawZ(out);
|
|
852
|
+
if (entries === null) return 'the staged file set could not be parsed';
|
|
853
|
+
if (entries.length === 0) return 'nothing is staged — stage the docs files first';
|
|
854
|
+
const bad = firstOutOfScopePath(entries.map((e) => e.path));
|
|
855
|
+
if (bad) return `staged path '${bad}' is not a docs path (owner publication is docs-only)`;
|
|
856
|
+
const nonReg = nonRegularDocsPayload(entries.filter((e) => e.status !== 'D'));
|
|
857
|
+
if (nonReg) return `staged path '${nonReg}' is a symlink or submodule, not a regular docs file`;
|
|
858
|
+
return '';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Push config that can widen or REDIRECT the pushed set/destination. The local
|
|
862
|
+
// upstream alone is NOT proof of the destination: remote.<r>.push refspecs,
|
|
863
|
+
// remote.<r>.mirror, push.followTags, branch.<b>.pushRemote and
|
|
864
|
+
// remote.pushDefault can all change what a plain `git push` actually sends.
|
|
865
|
+
function pushConfigDenial(remote, branch) {
|
|
866
|
+
const truthy = (v) => v !== null && !['false', '0', 'no', 'off'].includes(String(v).trim().toLowerCase());
|
|
867
|
+
const wide = [];
|
|
868
|
+
if (truthy(gitRead('config', '--get-all', 'push.followTags'))) wide.push('push.followTags widens the pushed set with tags');
|
|
869
|
+
if (truthy(gitRead('config', '--get-all', `remote.${remote}.mirror`))) wide.push(`remote.${remote}.mirror redirects/widens the push`);
|
|
870
|
+
if ((gitRead('config', '--get-all', 'remote.pushDefault') || '').trim()) wide.push('remote.pushDefault redirects the push destination');
|
|
871
|
+
if (branch && (gitRead('config', '--get-all', `branch.${branch}.pushRemote`) || '').trim()) wide.push(`branch.${branch}.pushRemote redirects the push destination`);
|
|
872
|
+
if ((gitRead('config', '--get-all', `remote.${remote}.push`) || '').trim()) wide.push(`remote.${remote}.push refspec config redirects the pushed set`);
|
|
873
|
+
const pdRaw = (gitRead('config', '--get-all', 'push.default') || '').trim();
|
|
874
|
+
if (pdRaw) {
|
|
875
|
+
const vals = pdRaw.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
876
|
+
if (vals.some((v) => !['simple', 'current', 'upstream', 'tracking', 'nothing'].includes(v))) {
|
|
877
|
+
wide.push(`push.default=${vals.join(',')} widens the pushed set beyond the verified ref`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return wide.length ? `push config cannot be verified here: ${wide.join('; ')}` : '';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Push verifies the OUTGOING COMMITS, not the net tree diff: a commit that added
|
|
884
|
+
// source and a later commit that reverted it hide each other in `diff A..B` but
|
|
885
|
+
// both go to the remote. Every outgoing commit's changed files must be docs-scope,
|
|
886
|
+
// every git read is failclosed, and the destination is pinned to the verified
|
|
887
|
+
// upstream (or, for a brand-new remote branch, to an explicit owner "create"
|
|
888
|
+
// order with a docs-only root off the remote default head).
|
|
889
|
+
function pushOutgoingDocsDenial(positionals, grant) {
|
|
890
|
+
const pos = Array.isArray(positionals) ? positionals : [];
|
|
891
|
+
const head = (gitRead('rev-parse', '--abbrev-ref', 'HEAD') || '').trim();
|
|
892
|
+
if (!head) return 'the current branch could not be read';
|
|
893
|
+
const noUpstreamMsg = 'the outgoing set cannot be verified — a first push of a new branch needs an explicit "git push -u <remote> <branch>" plus an owner order that says create/new; otherwise push once from an implementation session or configure upstream first';
|
|
894
|
+
let remote; let tracking; let creating = false;
|
|
895
|
+
if (pos.length === 0) {
|
|
896
|
+
tracking = (gitRead('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') || '').trim();
|
|
897
|
+
if (!tracking) return noUpstreamMsg;
|
|
898
|
+
remote = tracking.split('/')[0];
|
|
899
|
+
} else if (pos.length === 1) {
|
|
900
|
+
remote = pos[0];
|
|
901
|
+
tracking = `${remote}/${head}`;
|
|
902
|
+
} else {
|
|
903
|
+
remote = pos[0];
|
|
904
|
+
const br = pos[1].replace(/^refs\/heads\//, '');
|
|
905
|
+
if (br !== head) return `the pushed branch '${br}' is not the current branch ('${head}') — destination refspecs are not verified here`;
|
|
906
|
+
tracking = `${remote}/${br}`;
|
|
907
|
+
}
|
|
908
|
+
const trackingExists = gitRead('rev-parse', '--verify', '--quiet', `${tracking}^{commit}`) !== null;
|
|
909
|
+
if (!trackingExists && !creating) {
|
|
910
|
+
if (pos.length === 0) return noUpstreamMsg;
|
|
911
|
+
const cfgDeny = pushConfigDenial(remote, head);
|
|
912
|
+
if (cfgDeny) return cfgDeny;
|
|
913
|
+
if (!grant || !grant.creation) return `creating remote branch '${tracking.split('/')[1]}' needs an owner order that explicitly says create/new (e.g. "create branch and push the docs") — a plain push order cannot bound the outgoing set here`;
|
|
914
|
+
creating = true;
|
|
915
|
+
}
|
|
916
|
+
if (creating) {
|
|
917
|
+
// Bounded creation against the LIVE remote baseline, never a cached ref.
|
|
918
|
+
// ls-remote failure (null) denies; an empty listing is a real empty remote
|
|
919
|
+
// only when the command itself succeeded (exit 0 with no output).
|
|
920
|
+
// NOT --heads: that filter drops the HEAD/symref line the default-head pin needs.
|
|
921
|
+
const live = gitRead('ls-remote', '--symref', remote);
|
|
922
|
+
if (live === null) return `the live remote listing for '${remote}' could not be read — the creation baseline cannot be proven, so the push is denied`;
|
|
923
|
+
const heads = live.split('\n').filter((l) => /^[0-9a-f]{40}\trefs\/heads\//.test(l));
|
|
924
|
+
const headSymref = live.split('\n').find((l) => /^ref: refs\/heads\/\S+\tHEAD$/.test(l));
|
|
925
|
+
if (heads.some((l) => l.endsWith(`\trefs/heads/${head}`))) {
|
|
926
|
+
return `remote branch '${head}' already exists — a create order cannot re-create or bypass pinning for an existing branch`;
|
|
927
|
+
}
|
|
928
|
+
let base = null;
|
|
929
|
+
if (heads.length > 0) {
|
|
930
|
+
if (!headSymref) return `the live remote default head of '${remote}' is unknown — the creation outgoing set cannot be bounded [live=${JSON.stringify(live.slice(0, 200))}]`;
|
|
931
|
+
const defBranch = headSymref.replace(/^ref: refs\/heads\//, '').replace(/\tHEAD$/, '');
|
|
932
|
+
const defLine = heads.find((l) => l.endsWith(`\trefs/heads/${defBranch}`));
|
|
933
|
+
if (!defLine) return `the live remote default head '${defBranch}' has no commit — the creation outgoing set cannot be bounded`;
|
|
934
|
+
base = defLine.split('\t')[0];
|
|
935
|
+
if (gitRead('cat-file', '-e', `${base}^{commit}`) === null) {
|
|
936
|
+
return `the live remote default head ${base.slice(0, 10)} is not present locally — the creation outgoing set cannot be bounded`;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return verifyOutgoing(base, head, `creating ${tracking}`);
|
|
940
|
+
}
|
|
941
|
+
const cfgDeny = pushConfigDenial(remote, head);
|
|
942
|
+
if (cfgDeny) return cfgDeny;
|
|
943
|
+
const upstream = (gitRead('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}') || '').trim();
|
|
944
|
+
if (upstream !== tracking) return `the push target '${tracking}' does not match this branch's upstream${upstream ? ` '${upstream}'` : ' (none set)'} — the actual destination cannot be pinned, so it is denied`;
|
|
945
|
+
if ((gitRead('rev-list', '--count', `HEAD..${tracking}`) || '1').trim() !== '0') {
|
|
946
|
+
return `the local branch has diverged from ${tracking} — the push would be non-fast-forward`;
|
|
947
|
+
}
|
|
948
|
+
// Pin the ACTUAL destination: a stale remote-tracking ref (e.g. the remote was
|
|
949
|
+
// rewound after the local ref was recorded) can make a source-carrying push
|
|
950
|
+
// look like a no-op or docs-only. The local ref must equal the remote's real
|
|
951
|
+
// head right now; an unreadable answer denies.
|
|
952
|
+
const localTip = (gitRead('rev-parse', '--verify', '--quiet', `${tracking}^{commit}`) || '').trim();
|
|
953
|
+
const remoteTip = (gitRead('ls-remote', remote, `refs/heads/${head}`) || '').split('\t')[0].trim();
|
|
954
|
+
if (!localTip || !remoteTip || localTip !== remoteTip) {
|
|
955
|
+
return `the remote-tracking ref '${tracking}' does not match the remote's actual head (refs/heads/${head}) — a stale ref cannot bound the destination, so the push is denied`;
|
|
956
|
+
}
|
|
957
|
+
return verifyOutgoing(tracking, head, '');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Lists rev..head and checks every commit is docs-scope. Every git read is
|
|
961
|
+
// failclosed: a failed rev-list or diff-tree DENIES (never silently allows).
|
|
962
|
+
function verifyOutgoing(base, localRef, what) {
|
|
963
|
+
const revsOut = base === null ? gitRead('rev-list', localRef) : gitRead('rev-list', `${base}..${localRef}`);
|
|
964
|
+
if (revsOut === null) return `the outgoing commits could not be listed (git rev-list failed${what ? ` for ${what}` : ''}) — denied`;
|
|
965
|
+
const revs = revsOut.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
966
|
+
if (revs.length === 0) return ''; // nothing outgoing: the push is a no-op
|
|
967
|
+
if (revs.length > 400) return `${revs.length} outgoing commits exceed the verification bound`;
|
|
968
|
+
for (const sha of revs) {
|
|
969
|
+
const raw = gitRead('diff-tree', '--root', '-r', '-m', '--no-renames', '--no-commit-id', '-z', sha);
|
|
970
|
+
if (raw === null) return `outgoing commit ${sha.slice(0, 10)} could not be inspected (git diff-tree failed) — denied`;
|
|
971
|
+
const entries = parseRawZ(raw);
|
|
972
|
+
if (entries === null || entries.length === 0) return `outgoing commit ${sha.slice(0, 10)} could not be parsed — denied`;
|
|
973
|
+
const bad = firstOutOfScopePath(entries.map((e) => e.path));
|
|
974
|
+
if (bad) return `outgoing commit ${sha.slice(0, 10)} touches '${bad}', which is not a docs path (publication is docs-only)`;
|
|
975
|
+
const nonReg = nonRegularDocsPayload(entries.filter((e) => e.status !== 'D'));
|
|
976
|
+
if (nonReg) return `outgoing commit ${sha.slice(0, 10)} adds '${nonReg}' as a symlink or submodule, not a regular docs file`;
|
|
977
|
+
}
|
|
978
|
+
return '';
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function ownerGitPublishDenial(pub) {
|
|
982
|
+
const grants = await ownerGitPublishAuthorized();
|
|
983
|
+
const grant = grants[pub.op];
|
|
984
|
+
if (!grant || !grant.ok) {
|
|
985
|
+
return `the owner has not explicitly ordered a docs-only ${pub.op} in this session — an affirmative owner message naming "${pub.op}" and the docs scope is required; questions, history, quoted text, assistant text, tool output and this command's own "NO git commit" text never authorize`;
|
|
986
|
+
}
|
|
987
|
+
return pub.op === 'commit' ? stagedDocsDenial() : pushOutgoingDocsDenial(pub.positionals, grant);
|
|
988
|
+
}
|
|
989
|
+
|
|
566
990
|
// createScopeVerdict({toolName, filePath, filePaths, command, projectDir, intent})
|
|
567
991
|
// -> {block:boolean, cls:string, reason?:string}
|
|
568
992
|
async function createScopeVerdict({ toolName: tool, filePath, filePaths, command, projectDir, intent, patchUnparseable }) {
|
|
569
993
|
if (intent !== 'create') return { block: false, cls: 'not-create' };
|
|
570
994
|
const alt = 'Write docs/tests only, or run /ukit:handoff-fullstack (or start a new session) to change source.';
|
|
571
995
|
if (tool === 'Bash') {
|
|
572
|
-
|
|
996
|
+
const cmdText = String(command || '');
|
|
997
|
+
if (!shellWriteHit(cmdText)) return { block: false, cls: 'shell-read' };
|
|
998
|
+
// The owner-authorized publication exception is reachable only for a command
|
|
999
|
+
// that parses as ONE strictly-validated git commit/push; every other shell
|
|
1000
|
+
// write keeps the conservative block.
|
|
1001
|
+
let pub = null;
|
|
1002
|
+
if (/(?:^|[;&|\n\s])git\s+(?:commit|push)\b/.test(cmdText)) {
|
|
1003
|
+
const segs = splitShellSegments(cmdText);
|
|
1004
|
+
if (segs.length === 1) pub = parseGitPublication(segs[0]);
|
|
1005
|
+
}
|
|
1006
|
+
if (pub) {
|
|
1007
|
+
const why = await ownerGitPublishDenial(pub);
|
|
1008
|
+
if (!why) {
|
|
1009
|
+
await auditCreateDecision({ phase: intent, source: 'owner-turn', decision: 'allow', cls: 'owner-git-publish', op: pub.op });
|
|
1010
|
+
return { block: false, cls: 'owner-git-publish' };
|
|
1011
|
+
}
|
|
1012
|
+
return {
|
|
1013
|
+
block: true,
|
|
1014
|
+
cls: 'shell-write',
|
|
1015
|
+
reason: `handoff-create is planning-only: this git ${pub.op} was not allowed — ${why}. The exception needs an explicit owner message in this session plus a pure "git commit"/"git push" of docs-only files. Use Write/Edit for allowed docs/tests paths, or run /ukit:handoff-fullstack (or start a new session) to change source.`,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
573
1018
|
return {
|
|
574
1019
|
block: true,
|
|
575
1020
|
cls: 'shell-write',
|
|
@@ -738,6 +1183,19 @@ async function createScopeGate() {
|
|
|
738
1183
|
}
|
|
739
1184
|
|
|
740
1185
|
if (isAgent) {
|
|
1186
|
+
// Planning dispatches run in THIS checkout: any isolation request (worktree/remote,
|
|
1187
|
+
// native `isolation` string or `isolated: true`) is refused. Explicit none/false is fine.
|
|
1188
|
+
const isoTasks = Array.isArray(input?.tasks) && input.tasks.length ? input.tasks : [input];
|
|
1189
|
+
const isolated = isoTasks.some((t) => {
|
|
1190
|
+
if (!t || typeof t !== 'object') return false;
|
|
1191
|
+
const iso = t.isolation;
|
|
1192
|
+
const isoOn = Boolean(iso) && !['none', 'false', 'off'].includes(String(iso).trim().toLowerCase());
|
|
1193
|
+
return isoOn || t.isolated === true || String(t.isolated ?? '').toLowerCase() === 'true';
|
|
1194
|
+
});
|
|
1195
|
+
if (isolated) {
|
|
1196
|
+
await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: 'agent-isolation' });
|
|
1197
|
+
block('handoff-create is planning-only: planning dispatches run in this checkout and must omit the isolation field (no worktree/remote). Re-dispatch the same agent without `isolation`; do not change the agent.');
|
|
1198
|
+
}
|
|
741
1199
|
const type = delegatedAgentTypes(input).find((t) => !CREATE_AGENT_ALLOW.has(t));
|
|
742
1200
|
if (type !== undefined) {
|
|
743
1201
|
await auditCreateDecision({ phase: intent, source, decision: 'deny', cls: 'agent-delegation', agent: type || 'unspecified' });
|