@tyhld/conductor 0.12.44 → 0.12.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -0
- package/dist/relay.js +73 -0
- package/package.json +1 -1
- package/scripts/hooks/policy.py +240 -0
- package/scripts/hooks/test_autorun.py +1 -1
- package/scripts/hooks/test_bypass_deny.py +1 -1
- package/scripts/hooks/test_discipline.py +130 -0
- package/scripts/hooks/test_policy.py +2 -2
package/dist/cli.js
CHANGED
|
@@ -121,6 +121,8 @@ function loadConfig(args) {
|
|
|
121
121
|
}
|
|
122
122
|
// ★職人の種類(ADR-021)。未設定・知らない値は 'claude'(後退させない)。
|
|
123
123
|
const agentKind = parseAgentKind(process.env.CONDUCTOR_AGENT_KIND);
|
|
124
|
+
// ★1便1context(便 87a1a28a)。既定オフ。1/true/yes/on のときだけ入る。
|
|
125
|
+
const freshContext = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_FRESH_CONTEXT ?? '').trim());
|
|
124
126
|
// relay 設定(既定 off。off の間は既存の heartbeat 挙動を一切変えない)。
|
|
125
127
|
const relayEnabled = /^(1|true|yes|on)$/i.test((process.env.CONDUCTOR_RELAY_ENABLED ?? '').trim());
|
|
126
128
|
let relayIntervalSec = 15;
|
|
@@ -154,6 +156,7 @@ function loadConfig(args) {
|
|
|
154
156
|
relayIntervalSec,
|
|
155
157
|
promptIntervalSec,
|
|
156
158
|
agentKind,
|
|
159
|
+
freshContext,
|
|
157
160
|
};
|
|
158
161
|
}
|
|
159
162
|
/** 1回分の報告を送る。送信結果(HTTPステータス)をログ表示する。 */
|
package/dist/relay.js
CHANGED
|
@@ -1694,6 +1694,66 @@ export function hasPendingDeliveryInInput(cap) {
|
|
|
1694
1694
|
}
|
|
1695
1695
|
return block.join('\n').includes('(配達役より)');
|
|
1696
1696
|
}
|
|
1697
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1698
|
+
// 1便1context:配る直前に職人の会話を新しくする(便 87a1a28a・★既定オフ)
|
|
1699
|
+
//
|
|
1700
|
+
// 【なぜ /clear か(試験用の tmux で本物の Claude Code に当てて実測 2026-09-21)】
|
|
1701
|
+
// ・/clear で会話の記録のファイルが新しくなる(新しい会話の番号・前のファイルは閉じる)。
|
|
1702
|
+
// 終わりと始まりのフック(SessionEnd:clear・SessionStart:clear)も鳴る=旧context の終了。
|
|
1703
|
+
// ・/clear のあとの開始時の背負いは 59,849 トークン=起動した直後(59,713)とほぼ同じ。前の会話は持ち越さない。
|
|
1704
|
+
// ・職人を止めないので、囲いのふた(6bef35c8)も起動待ちも起きない。
|
|
1705
|
+
// ★道具のつなぎ直しはしない(起動時の「つなぎ始め」の記録が /clear のあとには無い)=あとから足した
|
|
1706
|
+
// 口は /clear では使えるようにならない。それが要る日は起こし直し(restart-cc2.sh)を使う。
|
|
1707
|
+
// 【新しくできたかの確かめ】受付側の状態で見る:その作業場所の会話の記録に、打つ前には無かった
|
|
1708
|
+
// ファイルができたか。確かめられなければ false(呼び出し側は配らない)。
|
|
1709
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
1710
|
+
/** 会話を新しくできたかを待つ長さ(ミリ秒)。 */
|
|
1711
|
+
export const RELAY_FRESH_CONFIRM_MS = 10_000;
|
|
1712
|
+
/**
|
|
1713
|
+
* 会話が新しくなってから配るまでに置く間(ミリ秒)。
|
|
1714
|
+
* ★実測(試験用の tmux・本物の Claude Code):新しい会話のファイルができた1秒後に貼り付けると、
|
|
1715
|
+
* 入力欄がまだ整っておらず【貼り付けた本文が落ち、添え書きだけが送信された】(職人は「貼り付けが
|
|
1716
|
+
* 届いていない」と答えて止まった)。間を置いたうえで、画面が入力待ちで落ち着いたことも確かめる。
|
|
1717
|
+
*/
|
|
1718
|
+
export const RELAY_FRESH_SETTLE_MS = 3_000;
|
|
1719
|
+
/** その作業場所の会話の記録のファイル名の一覧。読めなければ空。 */
|
|
1720
|
+
export function listTranscripts(cwd, env = process.env) {
|
|
1721
|
+
try {
|
|
1722
|
+
return readdirSync(claudeTranscriptDir(cwd, env)).filter((n) => n.endsWith('.jsonl'));
|
|
1723
|
+
}
|
|
1724
|
+
catch {
|
|
1725
|
+
return [];
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
export async function freshenContext(site, cwd, d = {
|
|
1729
|
+
listSessions: () => listTranscripts(cwd),
|
|
1730
|
+
type: (text) => runTmux(['send-keys', '-t', site, '-l', text]),
|
|
1731
|
+
enter: () => runTmux(['send-keys', '-t', site, 'Enter']),
|
|
1732
|
+
pause: pauseSync,
|
|
1733
|
+
sleep,
|
|
1734
|
+
ready: () => isIdleStable(site, 'claude'),
|
|
1735
|
+
}, o = { waitMs: RELAY_FRESH_CONFIRM_MS, pollMs: 500 }) {
|
|
1736
|
+
const before = new Set(d.listSessions());
|
|
1737
|
+
try {
|
|
1738
|
+
d.type('/clear');
|
|
1739
|
+
// ★打った字と Enter の間は一拍おく(b69c1608:すぐ続く Enter は改行に取り込まれることがある)。
|
|
1740
|
+
d.pause(RELAY_SUBMIT_PAUSE_MS);
|
|
1741
|
+
d.enter();
|
|
1742
|
+
}
|
|
1743
|
+
catch {
|
|
1744
|
+
return false;
|
|
1745
|
+
}
|
|
1746
|
+
for (let t = 0;; t += o.pollMs) {
|
|
1747
|
+
if (d.listSessions().some((n) => !before.has(n))) {
|
|
1748
|
+
// ★新しい会話ができた直後は入力欄が整っていない(RELAY_FRESH_SETTLE_MS の説明)。
|
|
1749
|
+
await d.sleep(RELAY_FRESH_SETTLE_MS);
|
|
1750
|
+
return d.ready();
|
|
1751
|
+
}
|
|
1752
|
+
if (t >= o.waitMs)
|
|
1753
|
+
return false;
|
|
1754
|
+
await d.sleep(o.pollMs);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1697
1757
|
/**
|
|
1698
1758
|
* 受け付けられるまで見て、未受付で入力欄に残っているときだけ Enter を押し直す。
|
|
1699
1759
|
* accepted … 押し直さずに受け付けられた
|
|
@@ -2641,6 +2701,19 @@ export async function relayTick(cfg, state, hooks) {
|
|
|
2641
2701
|
noteReadiness(state, 'not-agent', cmd.id);
|
|
2642
2702
|
return;
|
|
2643
2703
|
}
|
|
2704
|
+
// ── 1便1context(★既定オフ・便 87a1a28a) ──
|
|
2705
|
+
// 便を配る直前に職人の会話を新しくする(/clear)。★便を追っている間(state.pending)はここへ
|
|
2706
|
+
// 来ない=質問・確認待ちの便は同じ会話のまま続く。新しくできたと確かめられなければ配らない
|
|
2707
|
+
// (次の周でやり直す)。Codex の職人は対象外。
|
|
2708
|
+
if (cfg.freshContext && kindOf(cfg) === 'claude') {
|
|
2709
|
+
const cwd = tmuxPaneCwd(cfg.site);
|
|
2710
|
+
const fresh = cwd !== null && (await freshenContext(cfg.site, cwd));
|
|
2711
|
+
if (!fresh) {
|
|
2712
|
+
log(`WARN site=${cfg.site} id=${cmd.id} 会話を新しくできなかったので配らずに次の周でやり直します`);
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
log(`site=${cfg.site} id=${cmd.id} 会話を新しくしました(1便1context)`);
|
|
2716
|
+
}
|
|
2644
2717
|
// ── tmux 送信 ──
|
|
2645
2718
|
// この送信に固有のアンカー(buildAnchor(id))を本文末尾に埋め込む。
|
|
2646
2719
|
// 画面にエコーされたアンカー行を「今回ジョブの境界」として記録し、完了検出は
|
package/package.json
CHANGED
package/scripts/hooks/policy.py
CHANGED
|
@@ -3406,6 +3406,240 @@ def _gate_command(command: str, depth: int = 0):
|
|
|
3406
3406
|
return None
|
|
3407
3407
|
|
|
3408
3408
|
|
|
3409
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
3410
|
+
# 作法(DENY・便 977507a5・えふさん確定のトークン対策4「機械処理化」)
|
|
3411
|
+
#
|
|
3412
|
+
# 【何のため】次の3件は、毎便の本文に「やらない」と書いてきたが、番人は通していた(棚卸し 8ceae57d の
|
|
3413
|
+
# 実測で allow)。番人で止められれば、便の本文からその行を外せる(書かなくても守られる)。
|
|
3414
|
+
# ① git add -A(--all・-A を含む短い旗のまとめ・git add . ・git add :/)
|
|
3415
|
+
# ② cd を && でほかの命令とつなげる/cd とリダイレクトを同じ命令の行に混ぜる
|
|
3416
|
+
# ③ リポ全体・ホーム全体を指す再帰の検索(grep -r・rg・ag・ack・find)
|
|
3417
|
+
# 【★確実にその形と言える物だけを止める】命令の1行目を、引用符を守って字句に割ってから見る
|
|
3418
|
+
# (ヒアドキュメントの本文・引用符の中の文字は命令として読まない)。行き先が変数などで決まらない
|
|
3419
|
+
# 検索は止めない(誤って止めて職人が詰まらないため)。
|
|
3420
|
+
# 【★ほかの判定は変えない】decide_bash のいちばん最後(危険・関門をすべて当てたあと)に置く。
|
|
3421
|
+
# 今まで止まっていた物・人に聞いていた物は、理由も結果もそのまま。
|
|
3422
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
3423
|
+
|
|
3424
|
+
_DISCIPLINE_OPS = frozenset({'&&', '||', ';', '|', '&', '(', ')', ';;', '|&'})
|
|
3425
|
+
_DISCIPLINE_REDIRECTS = frozenset({'>', '>>', '<', '>&', '&>', '>|', '<>', '&>>', '<&'})
|
|
3426
|
+
|
|
3427
|
+
_DISC_GIT_ADD = ('git add はまとめて足さない(-A・--all・. は秘密や無関係の変更まで入る)。'
|
|
3428
|
+
'足すファイルを名指しで git add path/to/file(鉄則1)')
|
|
3429
|
+
_DISC_CD = ('cd を && でほかの命令とつなげない・cd とリダイレクトを同じ行に混ぜない(許可の確認で止まる)。'
|
|
3430
|
+
'cd は作業のはじめに1回だけ単独で打ち、以後は絶対パスか git -C を使う(鉄則2)')
|
|
3431
|
+
_DISC_SEARCH = ('リポ全体・ホーム全体を再帰で検索しない(重く、秘密の置き場にも当たる)。'
|
|
3432
|
+
'探す場所を名指しで(例: rg 語 src/ ・ find src -name 名前)')
|
|
3433
|
+
|
|
3434
|
+
|
|
3435
|
+
def _discipline_tokens(command: str):
|
|
3436
|
+
"""命令の1行目を、引用符を守って語と演算子に割る。割れなければ None(=判定しない)。"""
|
|
3437
|
+
first = (command or '').split('\n', 1)[0]
|
|
3438
|
+
if '<<' in first:
|
|
3439
|
+
first = first.split('<<', 1)[0] # ヒアドキュメントの始まりより後ろは見ない
|
|
3440
|
+
try:
|
|
3441
|
+
lx = shlex.shlex(first, posix=True, punctuation_chars=True)
|
|
3442
|
+
lx.whitespace_split = True
|
|
3443
|
+
return list(lx)
|
|
3444
|
+
except ValueError:
|
|
3445
|
+
return None
|
|
3446
|
+
|
|
3447
|
+
|
|
3448
|
+
def _discipline_commands(toks):
|
|
3449
|
+
"""字句の並びを、演算子で区切った1コマンドずつ [(前の演算子, 語の並び, 後ろの演算子)] にする。"""
|
|
3450
|
+
out, cur, before = [], [], None
|
|
3451
|
+
skip_next = False
|
|
3452
|
+
for t in toks:
|
|
3453
|
+
if skip_next:
|
|
3454
|
+
skip_next = False
|
|
3455
|
+
continue
|
|
3456
|
+
if t in _DISCIPLINE_OPS:
|
|
3457
|
+
if cur:
|
|
3458
|
+
out.append([before, cur, t])
|
|
3459
|
+
cur, before = [], t
|
|
3460
|
+
elif t in _DISCIPLINE_REDIRECTS:
|
|
3461
|
+
# リダイレクトとその行き先は、命令の語ではない(2>/dev/null の 2 も外す)。
|
|
3462
|
+
if cur and cur[-1].isdigit():
|
|
3463
|
+
cur.pop()
|
|
3464
|
+
skip_next = True
|
|
3465
|
+
else:
|
|
3466
|
+
cur.append(t)
|
|
3467
|
+
if cur:
|
|
3468
|
+
out.append([before, cur, None])
|
|
3469
|
+
return out
|
|
3470
|
+
|
|
3471
|
+
|
|
3472
|
+
def _disc_cd(cmds, toks):
|
|
3473
|
+
has_cd = any(words and words[0] == 'cd' for _, words, _ in cmds)
|
|
3474
|
+
if not has_cd:
|
|
3475
|
+
return None
|
|
3476
|
+
for before, words, after in cmds:
|
|
3477
|
+
if words and words[0] == 'cd' and ('&&' in (before, after)):
|
|
3478
|
+
return _DISC_CD
|
|
3479
|
+
if any(t in _DISCIPLINE_REDIRECTS for t in toks):
|
|
3480
|
+
return _DISC_CD
|
|
3481
|
+
return None
|
|
3482
|
+
|
|
3483
|
+
|
|
3484
|
+
def _disc_git_add(words):
|
|
3485
|
+
if not words or words[0].split('/')[-1] != 'git':
|
|
3486
|
+
return None
|
|
3487
|
+
i = 1
|
|
3488
|
+
while i < len(words) and words[i].startswith('-'):
|
|
3489
|
+
# git の全体の旗(-C 場所・-c 設定 は値を1つ取る)。
|
|
3490
|
+
i += 2 if words[i] in ('-C', '-c') else 1
|
|
3491
|
+
if i >= len(words) or words[i] != 'add':
|
|
3492
|
+
return None
|
|
3493
|
+
after_dd = False
|
|
3494
|
+
for a in words[i + 1:]:
|
|
3495
|
+
if not after_dd and a == '--':
|
|
3496
|
+
after_dd = True
|
|
3497
|
+
continue
|
|
3498
|
+
if not after_dd and a.startswith('-'):
|
|
3499
|
+
if a in ('-A', '--all') or (not a.startswith('--') and 'A' in a[1:]):
|
|
3500
|
+
return _DISC_GIT_ADD
|
|
3501
|
+
continue
|
|
3502
|
+
if a in ('.', './', ':/', ':'):
|
|
3503
|
+
return _DISC_GIT_ADD
|
|
3504
|
+
return None
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
def _disc_repo_top(cwd):
|
|
3508
|
+
d = os.path.normpath(cwd) if cwd and os.path.isabs(cwd) else None
|
|
3509
|
+
while d:
|
|
3510
|
+
if os.path.exists(os.path.join(d, '.git')):
|
|
3511
|
+
return d
|
|
3512
|
+
parent = os.path.dirname(d)
|
|
3513
|
+
if parent == d:
|
|
3514
|
+
return None
|
|
3515
|
+
d = parent
|
|
3516
|
+
return None
|
|
3517
|
+
|
|
3518
|
+
|
|
3519
|
+
def _disc_is_whole(target, cwd):
|
|
3520
|
+
"""その行き先が「リポ全体・ホーム全体(またはそれより広い所)」か。決まらなければ False。"""
|
|
3521
|
+
home = os.path.normpath(os.path.expanduser('~'))
|
|
3522
|
+
t = target
|
|
3523
|
+
for v in ('${HOME}', '$HOME'):
|
|
3524
|
+
if t == v or t.startswith(v + '/'):
|
|
3525
|
+
t = home + t[len(v):]
|
|
3526
|
+
if '$' in t or '`' in t or '*' in t or '?' in t:
|
|
3527
|
+
return False
|
|
3528
|
+
t = os.path.expanduser(t)
|
|
3529
|
+
if not os.path.isabs(t):
|
|
3530
|
+
if not (cwd and os.path.isabs(cwd)):
|
|
3531
|
+
return False
|
|
3532
|
+
t = os.path.join(cwd, t)
|
|
3533
|
+
t = os.path.normpath(t)
|
|
3534
|
+
wide = {'/', home}
|
|
3535
|
+
d = home
|
|
3536
|
+
while d != '/':
|
|
3537
|
+
d = os.path.dirname(d)
|
|
3538
|
+
wide.add(d)
|
|
3539
|
+
top = _disc_repo_top(cwd)
|
|
3540
|
+
if top:
|
|
3541
|
+
wide.add(top)
|
|
3542
|
+
d = top
|
|
3543
|
+
while d != '/':
|
|
3544
|
+
d = os.path.dirname(d)
|
|
3545
|
+
wide.add(d)
|
|
3546
|
+
return t in wide
|
|
3547
|
+
|
|
3548
|
+
|
|
3549
|
+
def _disc_search(words, cwd):
|
|
3550
|
+
if not words:
|
|
3551
|
+
return None
|
|
3552
|
+
v = words[0].split('/')[-1]
|
|
3553
|
+
targets = None
|
|
3554
|
+
if v in ('grep', 'egrep', 'fgrep'):
|
|
3555
|
+
flags = [w for w in words[1:] if w.startswith('-')]
|
|
3556
|
+
rec = any(f in ('--recursive', '--dereference-recursive') or
|
|
3557
|
+
(not f.startswith('--') and ('r' in f[1:] or 'R' in f[1:])) for f in flags)
|
|
3558
|
+
if not rec:
|
|
3559
|
+
return None
|
|
3560
|
+
pos, skip = [], False
|
|
3561
|
+
has_e = False
|
|
3562
|
+
for k, w in enumerate(words[1:], 1):
|
|
3563
|
+
if skip:
|
|
3564
|
+
skip = False
|
|
3565
|
+
continue
|
|
3566
|
+
if w in ('-e', '-f', '--regexp', '--file'):
|
|
3567
|
+
has_e, skip = True, True
|
|
3568
|
+
continue
|
|
3569
|
+
if w.startswith('-'):
|
|
3570
|
+
continue
|
|
3571
|
+
pos.append(w)
|
|
3572
|
+
targets = pos if has_e else pos[1:]
|
|
3573
|
+
elif v in ('rg', 'ag', 'ack'):
|
|
3574
|
+
pos, skip, has_e, files, depth = [], False, False, False, None
|
|
3575
|
+
for k, w in enumerate(words[1:], 1):
|
|
3576
|
+
if skip:
|
|
3577
|
+
if depth == 'next':
|
|
3578
|
+
depth = w
|
|
3579
|
+
skip = False
|
|
3580
|
+
continue
|
|
3581
|
+
if w in ('-e', '-f', '--regexp', '--file'):
|
|
3582
|
+
has_e, skip = True, True
|
|
3583
|
+
continue
|
|
3584
|
+
if w in ('--max-depth', '-d', '--depth'):
|
|
3585
|
+
depth, skip = 'next', True
|
|
3586
|
+
continue
|
|
3587
|
+
if w.startswith('--max-depth='):
|
|
3588
|
+
depth = w.split('=', 1)[1]
|
|
3589
|
+
continue
|
|
3590
|
+
if w == '--files':
|
|
3591
|
+
files = True
|
|
3592
|
+
continue
|
|
3593
|
+
if w in ('-g', '--glob', '-t', '--type', '-T', '--type-not', '-m', '--max-count',
|
|
3594
|
+
'-A', '-B', '-C', '--context'):
|
|
3595
|
+
skip = True
|
|
3596
|
+
continue
|
|
3597
|
+
if w.startswith('-'):
|
|
3598
|
+
continue
|
|
3599
|
+
pos.append(w)
|
|
3600
|
+
if depth is not None and str(depth).isdigit() and int(depth) <= 2:
|
|
3601
|
+
return None
|
|
3602
|
+
targets = pos if (has_e or files) else pos[1:]
|
|
3603
|
+
elif v == 'find':
|
|
3604
|
+
if '-maxdepth' in words:
|
|
3605
|
+
k = words.index('-maxdepth')
|
|
3606
|
+
if k + 1 < len(words) and words[k + 1].isdigit() and int(words[k + 1]) <= 2:
|
|
3607
|
+
return None
|
|
3608
|
+
targets = []
|
|
3609
|
+
for w in words[1:]:
|
|
3610
|
+
if w.startswith('-') or w in ('(', '!', ')'):
|
|
3611
|
+
break
|
|
3612
|
+
targets.append(w)
|
|
3613
|
+
else:
|
|
3614
|
+
return None
|
|
3615
|
+
if not targets:
|
|
3616
|
+
targets = ['.']
|
|
3617
|
+
if any(_disc_is_whole(t, cwd) for t in targets):
|
|
3618
|
+
return _DISC_SEARCH
|
|
3619
|
+
return None
|
|
3620
|
+
|
|
3621
|
+
|
|
3622
|
+
def _discipline_scan(command: str, cwd=None):
|
|
3623
|
+
"""作法の3件(git add -A・cd の && とリダイレクト・全体の再帰検索)。当たれば職人へ返す1行、無ければ None。"""
|
|
3624
|
+
toks = _discipline_tokens(command)
|
|
3625
|
+
if not toks:
|
|
3626
|
+
return None
|
|
3627
|
+
cmds = _discipline_commands(toks)
|
|
3628
|
+
hit = _disc_cd(cmds, toks)
|
|
3629
|
+
if hit:
|
|
3630
|
+
return hit
|
|
3631
|
+
for _, words, _ in cmds:
|
|
3632
|
+
# 包み(env 変数=… ・time ・nohup ・sudo 等)を剥がした先頭から見る。
|
|
3633
|
+
k = 0
|
|
3634
|
+
while k < len(words) and (_ASSIGN_RE.match(words[k]) or words[k].split('/')[-1] in _WRAPPERS):
|
|
3635
|
+
k += 1
|
|
3636
|
+
core = words[k:]
|
|
3637
|
+
hit = _disc_git_add(core) or _disc_search(core, cwd)
|
|
3638
|
+
if hit:
|
|
3639
|
+
return hit
|
|
3640
|
+
return None
|
|
3641
|
+
|
|
3642
|
+
|
|
3409
3643
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
3410
3644
|
# 入口: Bash
|
|
3411
3645
|
# ═════════════════════════════════════════════════════════════════════════════
|
|
@@ -3485,6 +3719,12 @@ def decide_bash(command: str, cwd=None):
|
|
|
3485
3719
|
gate, reason = hit
|
|
3486
3720
|
return _r(GATE, f'gate({gate}): {reason}', gate)
|
|
3487
3721
|
|
|
3722
|
+
# ---- 2b) 作法(便 977507a5・毎便の本文に書いていた決まりを番人で止める) ----
|
|
3723
|
+
# ★いちばん最後に置く=ほかの危険・関門の判定は1件も変わらない(今まで通っていた物だけが対象)。
|
|
3724
|
+
hit = _discipline_scan(command, cwd)
|
|
3725
|
+
if hit:
|
|
3726
|
+
return _r(DENY, f'discipline: {hit}')
|
|
3727
|
+
|
|
3488
3728
|
# ---- 3) それ以外は自走 ----
|
|
3489
3729
|
return _r(ALLOW, 'no gate, no danger')
|
|
3490
3730
|
|
|
@@ -146,7 +146,7 @@ class FakeDatalink:
|
|
|
146
146
|
|
|
147
147
|
print('── (a) 関門でない質問は1つも人に届かず、その場で allow が返る ' + '─' * 11)
|
|
148
148
|
for label, cmd in [
|
|
149
|
-
('ふつうの命令(cd)', 'cd /home/x/projects/demo
|
|
149
|
+
('ふつうの命令(cd)', 'cd /home/x/projects/demo; npm run build'), # ★&& でつなぐ形は作法で止まる(便 977507a5)
|
|
150
150
|
('ふつうの命令(npx)', 'npx tsc --noEmit'),
|
|
151
151
|
('削除', 'rm -f /tmp/claude-1000/tmpfile.txt'),
|
|
152
152
|
]:
|
|
@@ -122,7 +122,7 @@ for cmd in [f'git {PUSH}', f'git {PUSH} origin', f'git {PUSH} origin HEAD', f'gi
|
|
|
122
122
|
expect('(1) git リポでない=確かめられない', cmd, policy.DENY, NOT_A_REPO)
|
|
123
123
|
expect('(1) push 先が main の作業ブランチ', f'git {PUSH}', policy.DENY, REPO_FEAT_TO_MAIN)
|
|
124
124
|
expect('(1) cd で main のリポへ移ってから', f'cd {REPO_MAIN} && git {PUSH}', policy.DENY, REPO_FEAT)
|
|
125
|
-
expect('(1) cd で作業ブランチのリポへ移ってから', f'cd {REPO_FEAT}
|
|
125
|
+
expect('(1) cd で作業ブランチのリポへ移ってから', f'cd {REPO_FEAT}; git {PUSH}', policy.ALLOW,
|
|
126
126
|
REPO_MAIN)
|
|
127
127
|
expect('(1) git -C で main のリポ', f'git -C {REPO_MAIN} {PUSH}', policy.DENY, REPO_FEAT)
|
|
128
128
|
expect('(1) 作業場所が変数', f'cd $APP_DIR && git {PUSH}', policy.DENY, REPO_FEAT)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""作法の3件を番人で止める検査(便 977507a5・トークン対策4「機械処理化」)。
|
|
3
|
+
|
|
4
|
+
実行: python3 scripts/hooks/test_discipline.py
|
|
5
|
+
1件でも期待と違えば exit 1。
|
|
6
|
+
|
|
7
|
+
【何を守っているか】
|
|
8
|
+
(n) ★止まる形:git add -A/--all/./-Av/git -C 場所 add -A、
|
|
9
|
+
cd を && でつなぐ(前でも後ろでも・括弧の中でも)・cd とリダイレクトを同じ行に混ぜる、
|
|
10
|
+
リポ全体・ホーム全体・/ を指す再帰の検索(grep -r・rg・find、行き先を省いた形も)
|
|
11
|
+
(p) 止まってはいけない似た形:git add ファイル名・git add -- ファイル名・cd 単独・cd X; 命令・
|
|
12
|
+
名指しの場所だけの検索(rg 語 src・find src・grep -r 語 src)・深さを限った find・
|
|
13
|
+
git grep・行き先が変数で決まらない検索・ヒアドキュメントや引用符の中の文字
|
|
14
|
+
(m) 止めたときの文は「何がだめで、代わりにどうするか」を1行で
|
|
15
|
+
(x) ほかの判定は変わらない:危険(秘密・main への push)と関門(本番反映)は、作法より先に
|
|
16
|
+
今までどおりの理由で当たる
|
|
17
|
+
★空振り確認:直す前の版(origin/main)で流すと (n) が落ちる(報告に実測を書く)。
|
|
18
|
+
|
|
19
|
+
★この台本は検索の道具の名前を素の字で置かない語もある(組み立てて使う)。台本の行そのものが
|
|
20
|
+
番人の判定に当たって、検査が流せなくなるのを避けるため。
|
|
21
|
+
"""
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
27
|
+
|
|
28
|
+
import policy # noqa: E402
|
|
29
|
+
|
|
30
|
+
failures = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check(label, got, want):
|
|
34
|
+
ok = got == want
|
|
35
|
+
if not ok:
|
|
36
|
+
failures.append(f'{label}: got={got!r} want={want!r}')
|
|
37
|
+
print(f'{"OK " if ok else "NG "} {label:60} {str(got)[:50]}')
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# 使い捨てのリポ(一番上に .git がある場所)を作業場所にする。
|
|
41
|
+
REPO = tempfile.mkdtemp(prefix='disc-repo-')
|
|
42
|
+
os.mkdir(os.path.join(REPO, '.git'))
|
|
43
|
+
os.mkdir(os.path.join(REPO, 'src'))
|
|
44
|
+
SUB = os.path.join(REPO, 'src')
|
|
45
|
+
HOME = os.path.expanduser('~')
|
|
46
|
+
ADD = 'git ' + 'add'
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def decide(cmd, cwd=REPO):
|
|
50
|
+
return policy.decide_bash(cmd, cwd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
print('── (n) 止まる形 ' + '─' * 50)
|
|
54
|
+
NEGATIVE = [
|
|
55
|
+
('n1 git add -A', f'{ADD} -A'),
|
|
56
|
+
('n2 git add --all', f'{ADD} --all'),
|
|
57
|
+
('n3 git add .', f'{ADD} .'),
|
|
58
|
+
('n4 git add -Av(短い旗のまとめ)', f'{ADD} -Av'),
|
|
59
|
+
('n5 git -C 場所 add -A', f'git -C /x add -A'),
|
|
60
|
+
('n6 git add :/(全体)', f'{ADD} :/'),
|
|
61
|
+
('n7 cd X && 命令', 'cd /tmp/x && npm test'),
|
|
62
|
+
('n8 命令 && cd X', 'npm test && cd /tmp/x'),
|
|
63
|
+
('n9 (cd X && 命令)', '(cd sub && make)'),
|
|
64
|
+
('n10 cd とリダイレクト', 'cd /tmp/x > /tmp/o'),
|
|
65
|
+
('n11 cd; 命令 > ファイル(同じ行に混ぜる)', 'cd /tmp/x; ls > /tmp/o'),
|
|
66
|
+
('n12 cd X && 命令 <<ヒアドキュメント', "cd /tmp/x && python3 - <<'EOF'\nprint(1)\nEOF"),
|
|
67
|
+
('n13 grep -r 語 .', 'grep -r foo .'),
|
|
68
|
+
('n14 grep -rn 語(行き先なし=ここ)', 'grep -rn foo'),
|
|
69
|
+
('n15 rg 語(行き先なし=ここ)', 'r' + 'g foo'),
|
|
70
|
+
('n16 rg 語 .', 'r' + 'g foo .'),
|
|
71
|
+
('n17 find .', 'find . -name x'),
|
|
72
|
+
('n18 find ~', 'find ~ -name x'),
|
|
73
|
+
('n19 find ホームの絶対パス', f'find {HOME} -name x'),
|
|
74
|
+
('n20 rg 語 $HOME', 'r' + 'g foo $HOME'),
|
|
75
|
+
('n21 find /', 'find / -name x'),
|
|
76
|
+
('n22 リポの一番上を名指しした find', f'find {REPO} -name x'),
|
|
77
|
+
('n23 リポより広い所(リポの親)', f'find {os.path.dirname(REPO)} -name x'),
|
|
78
|
+
('n24 env 変数=… を前に付けても', f'GIT_TRACE=1 {ADD} -A'),
|
|
79
|
+
]
|
|
80
|
+
for label, cmd in NEGATIVE:
|
|
81
|
+
r = decide(cmd)
|
|
82
|
+
check(label, (r['decision'], r['reason'].startswith('discipline:')), ('deny', True))
|
|
83
|
+
|
|
84
|
+
print('── (p) 止まってはいけない似た形 ' + '─' * 34)
|
|
85
|
+
POSITIVE = [
|
|
86
|
+
('p1 git add ファイル名', f'{ADD} src/a.ts'),
|
|
87
|
+
('p2 git add -- ファイル名', f'{ADD} -- docs/x.md'),
|
|
88
|
+
('p3 git add -p ファイル名', f'{ADD} -p src/a.ts'),
|
|
89
|
+
('p4 cd 単独(作業のはじめの1回)', f'cd {REPO}'),
|
|
90
|
+
('p5 cd X; 命令', 'cd /tmp/x; npm test'),
|
|
91
|
+
('p6 rg 語 src', 'r' + 'g foo src'),
|
|
92
|
+
('p7 grep -r 語 src', 'grep -r foo src'),
|
|
93
|
+
('p8 find src', 'find src -name x'),
|
|
94
|
+
('p9 深さを限った find .', 'find . -maxdepth 1 -name x'),
|
|
95
|
+
('p10 rg --max-depth 1', 'r' + 'g --max-depth 1 foo'),
|
|
96
|
+
('p11 git grep(別の道具)', 'git grep foo'),
|
|
97
|
+
('p12 行き先が変数で決まらない', 'find $DIR -name x'),
|
|
98
|
+
('p13 引用符の中の && cd', 'echo "cd x && y"'),
|
|
99
|
+
('p14 コミットの説明に git add -A', 'git commit -m "git add -A はやめた"'),
|
|
100
|
+
('p15 ヒアドキュメントの本文に cd &&', "cat <<'EOF' > /tmp/claude-1000/n.txt\ncd x && y\nEOF"),
|
|
101
|
+
('p16 下の場所から find .(リポ全体ではない)', 'find . -name x'),
|
|
102
|
+
('p17 grep(再帰でない)', 'grep -n foo src/a.ts'),
|
|
103
|
+
('p18 リダイレクトだけ(cd なし)', 'ls > /tmp/claude-1000/o'),
|
|
104
|
+
]
|
|
105
|
+
for label, cmd in POSITIVE:
|
|
106
|
+
cwd = SUB if label.startswith('p16') else REPO
|
|
107
|
+
check(label, decide(cmd, cwd)['decision'], 'allow')
|
|
108
|
+
|
|
109
|
+
print('── (m) 止めたときの文 ' + '─' * 44)
|
|
110
|
+
check('(m) git add は名指しを案内', 'git add path/to/file' in decide(f'{ADD} -A')['reason'], True)
|
|
111
|
+
check('(m) cd は絶対パスか git -C を案内', 'git -C' in decide('cd x && ls')['reason'], True)
|
|
112
|
+
check('(m) 検索は名指しの場所を案内', '名指し' in decide('find . -name x')['reason'], True)
|
|
113
|
+
for label, cmd in [('git add', f'{ADD} -A'), ('cd', 'cd x && ls'), ('検索', 'find / -name x')]:
|
|
114
|
+
check(f'(m) {label} の文は1行', '\n' in decide(cmd)['reason'], False)
|
|
115
|
+
|
|
116
|
+
print('── (x) ほかの判定は変わらない(作法より先に当たる) ' + '─' * 14)
|
|
117
|
+
r = decide('cd /tmp/x && cat .env')
|
|
118
|
+
check('(x) 秘密の読み出しは危険の理由のまま', (r['decision'], r['reason'].startswith('danger:')), ('deny', True))
|
|
119
|
+
r = decide('cd /tmp/x && npx ver' + 'cel --prod')
|
|
120
|
+
check('(x) 本番反映は関門のまま(人に聞く)', r['decision'], 'gate')
|
|
121
|
+
r = decide('find ~ -name .env -exec cat {} +')
|
|
122
|
+
check('(x) ホームの秘密を探して読むのは危険の理由のまま', r['reason'].startswith('danger:'), True)
|
|
123
|
+
|
|
124
|
+
print()
|
|
125
|
+
if failures:
|
|
126
|
+
print(f'NG {len(failures)} 件')
|
|
127
|
+
for f in failures:
|
|
128
|
+
print(' ', f)
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
print('すべて期待どおり')
|
|
@@ -173,7 +173,7 @@ MERGE_ALLOW = [
|
|
|
173
173
|
'gh pr merge --rebase 7',
|
|
174
174
|
# ★API での合流(gh api -X PUT …/pulls/N/merge)は便 fc22bc34 で deny へ移した
|
|
175
175
|
# (保護の迂回・えふさん確定 2026-09-18)→ test_bypass_deny.py。
|
|
176
|
-
'cd /srv/app
|
|
176
|
+
'cd /srv/app; gh pr merge 12 --squash', # 複合コマンドでも自走(★&& は作法で止まる・便 977507a5)
|
|
177
177
|
'bash -c "gh pr merge 12 --squash"', # 包んでも自走
|
|
178
178
|
'gh pr merge 12 --squash && git pull',
|
|
179
179
|
# ★便(指示)で実際に使う形。鉄則7「便の終点はマージまで」の形をそのまま置く。
|
|
@@ -465,7 +465,7 @@ for cmd in [f'cat ~/{_CONDUCTOR_ENV}', f'head -5 ~/{_CONDUCTOR_ENV}',
|
|
|
465
465
|
'cat ~/.codex/auth.json', 'cat ~/.config/gh/hosts.yml']:
|
|
466
466
|
check(f'★どの経路でも deny {cmd[:52]}', policy.decide_bash(cmd)['decision'], policy.DENY)
|
|
467
467
|
# 既存の自走(ふつうの作業)は1つも止めない。
|
|
468
|
-
for cmd in ['cd ~/projects/x
|
|
468
|
+
for cmd in ['cd ~/projects/x; npm test', 'ls -la', 'npm run build', 'git status',
|
|
469
469
|
'cat README.md', 'grep -r TODO src/', 'gh pr create --title x']:
|
|
470
470
|
check(f'ふつうの作業は通る {cmd[:44]}', policy.decide_bash(cmd)['decision'], policy.ALLOW)
|
|
471
471
|
|