@asc-agent/runtime 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ proceed-by-default, escalation, audit, the external-write guard, host integratio
7
7
  here.
8
8
 
9
9
  ```bash
10
- npm install -g @asc-agent/runtime@0.7.1
10
+ npm install -g @asc-agent/runtime@0.8.0
11
11
  ```
12
12
 
13
13
  npm owns the executable link (on Windows, npm's own `asc.cmd`). This package never edits
@@ -156,6 +156,17 @@ export function segmentsOf(command) {
156
156
  flush();
157
157
  return segments;
158
158
  }
159
+ /**
160
+ * 이 조각이 ASC control-plane 명령인가 (E-02).
161
+ *
162
+ * **Guard 는 ASC 자신의 명령을 절대 막지 않는다.** 막는 쪽과 나가는 쪽이 동시에 닫히면
163
+ * 사람이 갇힌다 — 0.7.1 실측에서 raw write 는 Guard 가, `asc grant issue` 는 Host 가 막아
164
+ * 나갈 길이 없었다. Guard 가 지는 몫은 이 한 줄로 끝난다: 우리 명령은 통과시킨다.
165
+ * 실행할 권한이 있는지는 그 다음에 Core 가 판정한다.
166
+ */
167
+ function isControlPlane(bare) {
168
+ return /^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:[\w./~-]*\/)?asc(?:\.[cm]?js)?(?:\s|$)/.test(bare);
169
+ }
159
170
  /** 이 조각이 다른 명령을 문자열로 받아 실행하는 자리인가. */
160
171
  function runsGivenText(bare) {
161
172
  return /(^|\s)(?:sh|bash|zsh|dash|ksh)\s+(?:-[a-zA-Z]*\s+)*-c(\s|$)/.test(bare) || /(^|\s)eval(\s|$)/.test(bare);
@@ -169,6 +180,9 @@ function runsGivenText(bare) {
169
180
  */
170
181
  export function forbiddenIn(command, patterns) {
171
182
  for (const segment of segmentsOf(command)) {
183
+ // ASC control-plane 은 판정 대상이 아니다 (E-02). 여기가 그 불변식이 사는 한 자리다.
184
+ if (isControlPlane(segment.bare))
185
+ continue;
172
186
  for (const { pattern, label } of patterns) {
173
187
  if (pattern.test(segment.bare))
174
188
  return label;
@@ -229,224 +243,303 @@ export function hookScript() {
229
243
  // **판정 로직을 두 번 쓰지 않는다.** 위 함수들의 소스를 그대로 실어 나른다 — 손으로
230
244
  // 옮겨 적으면 언젠가 hook 과 단위 검사가 서로 다른 것을 막는다. 그래서 저 함수들은
231
245
  // 바깥 식별자를 참조하지 않는다.
232
- const logic = [segmentsOf, runsGivenText, forbiddenIn]
246
+ const logic = [segmentsOf, isControlPlane, runsGivenText, forbiddenIn]
233
247
  .map((fn) => fn.toString())
234
248
  .join('\n\n')
235
249
  .replace(/`/g, '\\`')
236
250
  .replace(/\$\{/g, '\\${');
237
- return `#!/usr/bin/env node
238
- // ASC external-write guard (PreToolUse) — 설치·갱신은 \`asc host claude install\` 로만.
239
- // 관리 대상(ASC RuntimeBinding에 등록된) Claude 세션의 외부 write를 실행 직전에 막는다.
240
- // ASC와 무관한 프로젝트·세션은 항상 통과한다.
241
- //
242
- // 이 파일에는 책임이 둘 있고 섞이면 안 된다:
243
- // safety — 금지 명령 차단. 실패하면 막아야 할 것이 나간다
244
- // telemetry — 활동 관찰. 실패하면 화면 한 줄이 빈다
245
- // telemetry는 try/catch 안에서만 돌고 어떤 exit 경로에도 관여하지 않는다.
246
- import { readFileSync, readdirSync, existsSync, writeFileSync, renameSync } from 'node:fs'
247
- import { homedir } from 'node:os'
248
- import { dirname, join, resolve } from 'node:path'
249
-
250
- const FORBIDDEN = [
251
- ${patterns}
252
- ]
253
-
254
- const OFFLINE_ONLY = [
255
- ${offlinePatterns}
256
- ]
257
-
258
- ${logic}
259
-
260
- /**
261
- * 원격이 얼어 있는가. 얼어 있으면 완전 오프라인인지까지 본다.
262
- * 읽지 못하면 얼지 않은 것으로 본다 — guard 오작동이 곧 작업 중단이 되면 안 된다.
263
- */
264
- function freezePolicy(ascRoot) {
265
- try {
266
- const { value } = JSON.parse(readFileSync(join(ascRoot, 'adapters', 'policy', 'freeze-policy.json'), 'utf8'))
267
- return JSON.parse(value)
268
- } catch {
269
- return null
270
- }
271
- }
272
-
273
- /** 경로 비교용 정규화. index가 쓰는 것과 같은 규칙이어야 한다. */
274
- function normalizePath(path) {
275
- const slashed = resolve(path).replace(/\\\\/g, '/').replace(/\\/+$/, '')
276
- return /^[a-zA-Z]:/.test(slashed) ? slashed[0].toUpperCase() + slashed.slice(1) : slashed
277
- }
278
-
279
- /**
280
- * user-owned runtime의 역색인에서 경로의 workspace를 찾는다.
281
- *
282
- * hook은 Bash 호출마다 도는 무의존 단일 파일이다 — 그래서 **읽기 한 번, 파싱 한 번**이
283
- * 상한이다. 조회는 문자열 비교뿐이고 파일시스템을 더 뒤지지 않는다.
284
- *
285
- * 반환은 세 갈래다:
286
- * { root } 이 경로는 등록된 workspace다
287
- * 'MISSING' 등록은 있는데 runtime을 읽는다 판정 불능
288
- * null index 자체가 없거나 이 경로가 등록돼 있지 않다
289
- */
290
- function lookupWorkspace(start) {
291
- const home = process.env.ASC_HOME || join(homedir(), '.asc')
292
- let index
293
- try {
294
- index = JSON.parse(readFileSync(join(home, 'workspace-index.json'), 'utf8'))
295
- } catch {
296
- return null // index가 없으면 user-owned runtime을 쓰지 않는 설치다
297
- }
298
- const locators = (index && index.locators) || {}
299
- let path = normalizePath(start)
300
- for (;;) {
301
- const entry = locators[path]
302
- if (entry) return existsSync(entry.root) ? { root: entry.root } : 'MISSING'
303
- const parent = path.slice(0, path.lastIndexOf('/'))
304
- if (!parent || parent === path || /^[a-zA-Z]:$/.test(path)) return null
305
- path = parent
306
- }
307
- }
308
-
309
- /** 저장소 안의 .asc 팀이 채택했거나 아직 이전하지 않은 개인 상태. */
310
- function findAscRoot(start) {
311
- let dir = resolve(start)
312
- const stop = normalizePath(homedir())
313
- for (;;) {
314
- // 홈의 ~/.asc 는 user runtime이지 프로젝트 상태가 아니다 — 프로젝트로 읽지 않는다
315
- if (normalizePath(dir) === stop) return null
316
- const candidate = join(dir, '.asc')
317
- if (existsSync(candidate)) return candidate
318
- const parent = dirname(dir)
319
- if (parent === dir) return null
320
- dir = parent
321
- }
322
- }
323
-
324
- /** 관리 대상 세션을 찾는다. 어느 Logical Session 소속인지까지 알아야 관찰을 남길 수 있다. */
325
- function findManaged(ascRoot, sessionId) {
326
- const dir = join(ascRoot, 'adapters', 'claude-code')
327
- if (!existsSync(dir)) return null
328
- for (const name of readdirSync(dir)) {
329
- if (!name.startsWith('runtime-binding') || !name.endsWith('.json')) continue
330
- try {
331
- const { value } = JSON.parse(readFileSync(join(dir, name), 'utf8'))
332
- const binding = JSON.parse(value)
333
- if (binding.physicalSessionId === sessionId || binding.workerId === sessionId) return binding
334
- } catch {
335
- // 깨진 binding은 판별 근거가 된다 항목만 건너뛴다
336
- }
337
- }
338
- return null
339
- }
340
-
341
- let input
342
- try {
343
- input = JSON.parse(readFileSync(0, 'utf8'))
344
- } catch {
345
- process.exit(0) // 입력을 못 읽으면 판단하지 않는다 — guard 오작동으로 전부 막는 것이 더 나쁘다
346
- }
347
-
348
- // 파일을 바꾸는 도구는 **일을 시작한다는 신호**다 (F6). 읽기는 여기 없다 — 상태를 보는
349
- // 세션까지 관리 대상으로 끌어들이면 그것은 자동화가 아니라 방해다.
350
- const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
351
- const isMutation = MUTATORS.has(String(input.tool_name ?? ''))
352
- if (input.tool_name !== 'Bash' && !isMutation) process.exit(0)
353
- const command = String(input.tool_input?.command ?? '')
354
-
355
- const cwd = input.cwd ?? process.cwd()
356
- const observedSessionId = String(input.session_id ?? '')
357
-
358
- // 등록된 workspace가 먼저다. 없으면 저장소 안 .asc 로 내려간다 (C-11 §3 우선순위).
359
- const registered = lookupWorkspace(cwd)
360
- if (registered === 'MISSING') {
361
- // **조건부 fail-closed** (C-11 §4). 이 경로는 ASC가 맡은 곳인데 runtime을 읽지 못했다.
362
- // 그대로 통과시키면 관리 대상 세션의 외부 write가 조용히 열린다 — 그게 가장 나쁘다.
363
- const blocked = forbiddenIn(command, FORBIDDEN)
364
- if (blocked) {
365
- console.error(
366
- \`[ASC guard] 경로는 ASC workspace로 등록돼 있는데 runtime을 읽지 못했다. \` +
367
- \`'\${blocked}' 막는다 — asc setup status 로 확인하라.\`,
368
- )
369
- process.exit(2)
370
- }
371
- process.exit(0)
372
- }
373
-
374
- const ascRoot = registered ? registered.root : findAscRoot(cwd)
375
- // ASC와 무관한 일반 세션이다 — 소유권을 주장하지 않는다
376
- if (!ascRoot) process.exit(0)
377
-
378
- const managed = findManaged(ascRoot, observedSessionId)
379
-
380
- // **일이 시작되는데 논리 세션이 없다** (F6). 사람이 "ASC 적용해" 라고 말해야 했던 자리다.
381
- // 여기서 막고 다음 걸음을 그대로 준다 명령을 실행하는 것은 agent 이고, 사람이
382
- // 아니다. 세션에 들어간 뒤에는 문이 다시 열린다.
383
- if (isMutation && !managed) {
384
- const id = observedSessionId || '<this session id>'
385
- console.error(
386
- [
387
- '[ASC] 이 workspace 는 ASC 가 관리한다. 파일을 바꾸기 전에 논리 세션 안에 들어가라.',
388
- ' asc proceed --work <WORK-KEY> --json # 작업 항목이 있으면',
389
- ' asc proceed --json # 이어갈 세션을 고르거나 계약을 제안받는다',
390
- ' asc host claude bind <S-ID> --physical ' + id,
391
- '읽기·조회는 막지 않는다 막는 것은 관리 밖의 변경뿐이다.',
392
- ].join('\\n'),
393
- )
394
- process.exit(2)
395
- }
396
-
397
- // **관리 대상 workspace 인데 이 Run 이 어느 계약에도 들어 있지 않다** (0.7.0 B-1).
398
- //
399
- // 지금까지 여기서 통과시켰다. 그래서 결합이 사라지거나 아직 생기지 않은 상태의 세션은
400
- // 계약 밖에서 밖으로 쓸 있었다 guard 있는데 열려 있는 상태이고, 그것이 가장 나쁘다.
401
- // 읽기는 그대로 통과한다. 막는 것은 밖으로 나가는 쓰기뿐이다.
402
- if (!managed) {
403
- const outward = forbiddenIn(command, FORBIDDEN)
404
- if (outward) {
405
- const id = observedSessionId || '<this session id>'
406
- console.error(
407
- [
408
- \`[ASC guard] 이 workspace 는 ASC 가 관리한다. '\${outward}' 는 논리 세션 밖에서 나갈 수 없다.\`,
409
- ' asc proceed --work <WORK-KEY> --json # 작업 항목이 있으면',
410
- ' asc proceed --json # 이어갈 세션을 고르거나 계약을 제안받는다',
411
- ' asc host claude bind <S-ID> --physical ' + id,
412
- '읽기·조회는 막지 않는다 — 막는 것은 밖으로 나가는 쓰기뿐이다.',
413
- ].join('\\n'),
414
- )
415
- process.exit(2)
416
- }
417
- process.exit(0)
418
- }
419
-
420
- // 관찰은 여기서 끝난다 — 아래 차단 판정은 이 호출의 성패를 보지 않는다
421
- try {
422
- recordActivity(ascRoot, managed, observedSessionId, String(input.tool_name ?? ''))
423
- } catch {}
424
-
425
- // 변경 도구는 여기까지다 아래 목록은 Bash 명령에 대한 것이다.
426
- if (isMutation) process.exit(0)
427
-
428
- const forbidden = forbiddenIn(command, FORBIDDEN)
429
- if (forbidden) {
430
- console.error(
431
- \`[ASC guard] '\${forbidden}' 는 ASC-managed 세션에서 금지다. \` +
432
- \`외부 반영은 승인된 Execution Grant(asc grant run)로만 나간다.\`,
433
- )
434
- process.exit(2) // exit 2 = 도구 실행 차단
435
- }
436
-
437
- // 완전 오프라인 선언이 있을 때만 읽기까지 막는다. 로컬 작업은 얼리지 않는다.
438
- const freeze = freezePolicy(ascRoot)
439
- if (freeze && freeze.frozen && freeze.denyRemoteRead) {
440
- const offline = forbiddenIn(command, OFFLINE_ONLY)
441
- if (offline) {
442
- console.error(
443
- \`[ASC guard] 완전 오프라인이다\${freeze.reason ? ' (' + freeze.reason + ')' : ''} '\${offline}' 를 막는다. \` +
444
- \`로컬 작업은 그대로 된다. 녹이려면 asc thaw.\`,
445
- )
446
- process.exit(2)
447
- }
448
- }
449
-
450
- process.exit(0)
251
+ return `#!/usr/bin/env node
252
+ // ASC external-write guard (PreToolUse) — 설치·갱신은 \`asc host claude install\` 로만.
253
+ // 관리 대상(ASC RuntimeBinding에 등록된) Claude 세션의 외부 write를 실행 직전에 막는다.
254
+ // ASC와 무관한 프로젝트·세션은 항상 통과한다.
255
+ //
256
+ // 이 파일에는 책임이 둘 있고 섞이면 안 된다:
257
+ // safety — 금지 명령 차단. 실패하면 막아야 할 것이 나간다
258
+ // telemetry — 활동 관찰. 실패하면 화면 한 줄이 빈다
259
+ // telemetry는 try/catch 안에서만 돌고 어떤 exit 경로에도 관여하지 않는다.
260
+ import { readFileSync, readdirSync, existsSync, writeFileSync, renameSync } from 'node:fs'
261
+ import { homedir } from 'node:os'
262
+ import { dirname, join, resolve } from 'node:path'
263
+
264
+ const FORBIDDEN = [
265
+ ${patterns}
266
+ ]
267
+
268
+ const OFFLINE_ONLY = [
269
+ ${offlinePatterns}
270
+ ]
271
+
272
+ ${logic}
273
+
274
+ /**
275
+ * workspace 실행 (0.8.0 Axis C · 보정 P0-1).
276
+ *
277
+ * **세 자리를 가른다.** 두 개로 뭉치면 어느 쪽이든 한 번은 틀린다:
278
+ *
279
+ * 파일 없음 ADVISE 아무도 고르지 않았다. 검사되지 않은 강제를 켜지 않는다
280
+ * 파일 있고 AUTO ENFORCE 사람이 고르고 readiness 통과한 상태다
281
+ * 파일 있고 MANUAL ADVISE 사람이 고른 상태다
282
+ * 파일 있는데 못 읽음 ENFORCE AUTO 였을 수도 있다 — 모르는 것을 여는 쪽으로 기울지 않는다
283
+ *
284
+ * 마지막 자리가 이 함수가 다시 쓰인 이유다. 예전에는 읽기 실패를 MANUAL 로 답했고,
285
+ * 그러면 저장돼 있던 AUTO 가 파일 손상·권한·I/O 하나로 조용히 풀린다 (fail-open).
286
+ * 여기서 강제한다고 해서 AUTO 라고 말하지는 않는다 — 화면에는 "읽지 못했다" 로 나간다.
287
+ */
288
+ function executionState(ascRoot) {
289
+ const file = join(ascRoot, 'adapters', 'policy', 'execution-mode.json')
290
+ let raw
291
+ try {
292
+ raw = readFileSync(file, 'utf8')
293
+ } catch (error) {
294
+ // 없는 것과 읽는 것은 다르다. ENOENT 만 "고르지 않았다" 다.
295
+ if (error && error.code === 'ENOENT') return { enforcement: 'ADVISE', mode: 'MANUAL', chosen: false }
296
+ return { enforcement: 'ENFORCE', degraded: 'MODE_STATE_UNREADABLE' }
297
+ }
298
+ try {
299
+ const mode = JSON.parse(JSON.parse(raw).value).mode
300
+ if (mode !== 'AUTO' && mode !== 'MANUAL') return { enforcement: 'ENFORCE', degraded: 'MODE_STATE_INVALID' }
301
+ return { enforcement: mode === 'AUTO' ? 'ENFORCE' : 'ADVISE', mode, chosen: true }
302
+ } catch {
303
+ return { enforcement: 'ENFORCE', degraded: 'MODE_STATE_INVALID' }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * 원격이 얼어 있는가. 얼어 있으면 완전 오프라인인지까지 본다.
309
+ * 읽지 못하면 얼지 않은 것으로 본다 — guard 오작동이 곧 작업 중단이 되면 안 된다.
310
+ */
311
+ function freezePolicy(ascRoot) {
312
+ try {
313
+ const { value } = JSON.parse(readFileSync(join(ascRoot, 'adapters', 'policy', 'freeze-policy.json'), 'utf8'))
314
+ return JSON.parse(value)
315
+ } catch {
316
+ return null
317
+ }
318
+ }
319
+
320
+ /** 경로 비교용 정규화. index가 쓰는 것과 같은 규칙이어야 한다. */
321
+ function normalizePath(path) {
322
+ const slashed = resolve(path).replace(/\\\\/g, '/').replace(/\\/+$/, '')
323
+ return /^[a-zA-Z]:/.test(slashed) ? slashed[0].toUpperCase() + slashed.slice(1) : slashed
324
+ }
325
+
326
+ /**
327
+ * user-owned runtime의 역색인에서 이 경로의 workspace를 찾는다.
328
+ *
329
+ * hook은 Bash 호출마다 도는 무의존 단일 파일이다 — 그래서 **읽기 한 번, 파싱 한 번**이
330
+ * 상한이다. 조회는 문자열 비교뿐이고 파일시스템을 더 뒤지지 않는다.
331
+ *
332
+ * 반환은 세 갈래다:
333
+ * { root } 이 경로는 등록된 workspace다
334
+ * 'MISSING' 등록은 있는데 runtime을 못 읽는다 — 판정 불능
335
+ * null index 자체가 없거나 이 경로가 등록돼 있지 않다
336
+ */
337
+ function lookupWorkspace(start) {
338
+ const home = process.env.ASC_HOME || join(homedir(), '.asc')
339
+ let index
340
+ try {
341
+ index = JSON.parse(readFileSync(join(home, 'workspace-index.json'), 'utf8'))
342
+ } catch {
343
+ return null // index가 없으면 user-owned runtime을 쓰지 않는 설치다
344
+ }
345
+ const locators = (index && index.locators) || {}
346
+ let path = normalizePath(start)
347
+ for (;;) {
348
+ const entry = locators[path]
349
+ if (entry) return existsSync(entry.root) ? { root: entry.root } : 'MISSING'
350
+ const parent = path.slice(0, path.lastIndexOf('/'))
351
+ if (!parent || parent === path || /^[a-zA-Z]:$/.test(path)) return null
352
+ path = parent
353
+ }
354
+ }
355
+
356
+ /** 저장소 안의 .asc — 팀이 채택했거나 아직 이전하지 않은 개인 상태. */
357
+ function findAscRoot(start) {
358
+ let dir = resolve(start)
359
+ const stop = normalizePath(homedir())
360
+ for (;;) {
361
+ // 홈의 ~/.asc 는 user runtime이지 프로젝트 상태가 아니다 — 프로젝트로 읽지 않는다
362
+ if (normalizePath(dir) === stop) return null
363
+ const candidate = join(dir, '.asc')
364
+ if (existsSync(candidate)) return candidate
365
+ const parent = dirname(dir)
366
+ if (parent === dir) return null
367
+ dir = parent
368
+ }
369
+ }
370
+
371
+ /** 관리 대상 세션을 찾는다. 어느 Logical Session 소속인지까지 알아야 관찰을 남길 수 있다. */
372
+ function findManaged(ascRoot, sessionId) {
373
+ const dir = join(ascRoot, 'adapters', 'claude-code')
374
+ if (!existsSync(dir)) return null
375
+ for (const name of readdirSync(dir)) {
376
+ if (!name.startsWith('runtime-binding') || !name.endsWith('.json')) continue
377
+ try {
378
+ const { value } = JSON.parse(readFileSync(join(dir, name), 'utf8'))
379
+ const binding = JSON.parse(value)
380
+ if (binding.physicalSessionId === sessionId || binding.workerId === sessionId) return binding
381
+ } catch {
382
+ // 깨진 binding은 판별 근거가 못 된다 — 그 항목만 건너뛴다
383
+ }
384
+ }
385
+ return null
386
+ }
387
+
388
+ let input
389
+ try {
390
+ input = JSON.parse(readFileSync(0, 'utf8'))
391
+ } catch {
392
+ process.exit(0) // 입력을 읽으면 판단하지 않는다 — guard 오작동으로 전부 막는 것이 더 나쁘다
393
+ }
394
+
395
+ // 파일을 바꾸는 도구는 **일을 시작한다는 신호**다 (F6). 읽기는 여기 없다 상태를 보는
396
+ // 세션까지 관리 대상으로 끌어들이면 그것은 자동화가 아니라 방해다.
397
+ const MUTATORS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
398
+ const isMutation = MUTATORS.has(String(input.tool_name ?? ''))
399
+ if (input.tool_name !== 'Bash' && !isMutation) process.exit(0)
400
+ const command = String(input.tool_input?.command ?? '')
401
+
402
+ const cwd = input.cwd ?? process.cwd()
403
+ const observedSessionId = String(input.session_id ?? '')
404
+
405
+ // 등록된 workspace가 먼저다. 없으면 저장소 .asc 로 내려간다 (C-11 §3 우선순위).
406
+ const registered = lookupWorkspace(cwd)
407
+ if (registered === 'MISSING') {
408
+ // 이 경로는 ASC가 맡은 곳인데 runtime을 읽지 못했다. **막지는 않는다** — mode 가 그
409
+ // runtime 안에 있으므로, 읽지 못한 상태에서 차단하면 고르지 않은 enforcement 를 켜는
410
+ // 것이 된다(0.8.0 §B). 대신 그 사실을 말한다: 무엇이 깨졌는지 사람이 알아야 한다.
411
+ if (forbiddenIn(command, FORBIDDEN)) {
412
+ console.error(
413
+ '[ASC] 경로는 ASC workspace 등록돼 있는데 runtime 읽지 못했다 — ' +
414
+ '실행 축(MANUAL/AUTO)을 확인할없다. asc status 확인하라.',
415
+ )
416
+ }
417
+ process.exit(0)
418
+ }
419
+
420
+ const ascRoot = registered ? registered.root : findAscRoot(cwd)
421
+ // ASC와 무관한 일반 세션이다 — 소유권을 주장하지 않는다
422
+ if (!ascRoot) process.exit(0)
423
+
424
+ const managed = findManaged(ascRoot, observedSessionId)
425
+ const state = executionState(ascRoot)
426
+
427
+ // 관찰은 차단과 섞이지 않는다 — 실패해도 아래 판정에 닿지 않고, 두 mode 모두에서 돈다.
428
+ // 일을 관리하는 것(Agent Management)은 실행을 누가 하느냐(Execution Mode)와 다른 축이다.
429
+ if (managed) {
430
+ try {
431
+ recordActivity(ascRoot, managed, observedSessionId, String(input.tool_name ?? ''))
432
+ } catch {}
433
+ }
434
+
435
+ // 완전 오프라인 선언이 있을 때만 읽기까지 막는다 — 이것은 Execution Mode 가 아니라
436
+ // 사람이 직접 스위치이므로 두 mode 모두에 선다. 녹이려면 \`asc thaw\`.
437
+ if (!isMutation) {
438
+ const freeze = freezePolicy(ascRoot)
439
+ if (freeze && freeze.frozen && freeze.denyRemoteRead) {
440
+ const offline = forbiddenIn(command, OFFLINE_ONLY)
441
+ if (offline) {
442
+ console.error(
443
+ \`[ASC guard] 완전 오프라인이다\${freeze.reason ? ' (' + freeze.reason + ')' : ''} — '\${offline}' 를 막는다. \` +
444
+ \`로컬 작업은 그대로 된다. 녹이려면 asc thaw.\`,
445
+ )
446
+ process.exit(2)
447
+ }
448
+ }
449
+ }
450
+
451
+ // ── MANUAL 막지 않는다. 다만 말은 한다 (0.8.0 §F) ─────────────────────────
452
+ //
453
+ // MANUAL ASC OFF 아니다: 일 관리·결정권·검수·감사는 그대로 돈다. 달라지는 것은
454
+ // 강제 라우팅 하나다. 그래서 이 자리의 guard 는 감시자가 아니라 조언자다 —
455
+ // 밖으로 나가는 쓰기를 보면 한 줄 남기고 그대로 통과시킨다.
456
+ //
457
+ // **여기서 대상을 해석하지 않는다** (§H). 어느 프로젝트인지·어느 SHA 인지 판정하는 것은
458
+ // Remote Review 일이고, 그 검수는 \`asc work publish --review\` 가 읽기만으로 보여 준다.
459
+ if (state.enforcement === 'ADVISE') {
460
+ const outward = forbiddenIn(command, FORBIDDEN)
461
+ if (outward) {
462
+ console.error(
463
+ [
464
+ \`[ASC] MANUAL — '\${outward}' 를 막지 않는다. 이 쓰기는 ASC 가 관리하는 경로 밖으로 나간다.\`,
465
+ ' asc work publish --review # 대상·SHA·결합을 읽기만으로 검수한다',
466
+ ' asc mode auto # 관리 경로로만 나가게 하려면',
467
+ ].join('\\n'),
468
+ )
469
+ }
470
+ process.exit(0)
471
+ }
472
+
473
+ // ── 실행 축 기록을 읽지 못했다 (P0-1) ────────────────────────────────────────
474
+ //
475
+ // AUTO 라고 말하지 않는다. 그러나 열어 두지도 않는다 — 저장돼 있던 것이 AUTO 였을 수 있고,
476
+ // 그것을 파일 하나로 푸는 것이 fail-open 이다. ASC 명령은 위에서 이미 통과했으므로 복구
477
+ // 경로는 그대로 열려 있다.
478
+ if (state.degraded) {
479
+ const outward = forbiddenIn(command, FORBIDDEN)
480
+ if (outward) {
481
+ console.error(
482
+ [
483
+ \`[ASC guard] \${state.degraded} — 이 workspace 의 실행 축을 읽지 못했다.\`,
484
+ \`'\${outward}' 는 그 상태에서 나가지 않는다. 저장돼 있던 것이 AUTO 였을 수 있다.\`,
485
+ ' asc status # 무엇이 깨졌는지 본다',
486
+ ' asc mode # 지금 상태를 그대로 보여 준다',
487
+ 'ASC 명령은 막히지 않는다 — 복구는 그쪽으로 한다.',
488
+ ].join('\\n'),
489
+ )
490
+ process.exit(2)
491
+ }
492
+ process.exit(0)
493
+ }
494
+
495
+ // ── AUTO 에서만 서는 문 ───────────────────────────────────────────────────────
496
+ if (state.mode === 'AUTO') {
497
+ // **일이 시작되는데 논리 세션이 없다** (F6). 사람이 "ASC 적용해" 라고 말해야 했던 자리다.
498
+ // 여기서 막고 다음 한 걸음을 그대로 준다. 세션에 들어간 뒤에는 이 문이 다시 열린다.
499
+ if (isMutation && !managed) {
500
+ const id = observedSessionId || '<this session id>'
501
+ console.error(
502
+ [
503
+ '[ASC] 이 workspace 는 ASC 가 자동 실행(AUTO)으로 관리한다. 파일을 바꾸기 전에 논리 세션 안에 들어가라.',
504
+ ' asc work start <WORK-KEY> # 작업 항목이 있으면',
505
+ ' asc work start # 이어갈 세션을 고르거나 계약을 제안받는다',
506
+ '읽기·조회는 막지 않는다 — 막는 것은 관리 밖의 변경뿐이다.',
507
+ '자동 실행을 원하지 않으면: asc mode manual',
508
+ ].join('\\n'),
509
+ )
510
+ process.exit(2)
511
+ }
512
+
513
+ // **관리 대상 workspace 인데 이 Run 이 어느 계약에도 들어 있지 않다** (0.7.0 B-1).
514
+ // 읽기는 그대로 통과한다. 막는 것은 밖으로 나가는 쓰기뿐이다.
515
+ if (!managed) {
516
+ const outward = forbiddenIn(command, FORBIDDEN)
517
+ if (outward) {
518
+ const id = observedSessionId || '<this session id>'
519
+ console.error(
520
+ [
521
+ \`[ASC guard] 이 workspace 는 AUTO 로 관리한다. '\${outward}' 는 논리 세션 밖에서 나갈 수 없다.\`,
522
+ ' asc work start <WORK-KEY> # 작업 항목이 있으면',
523
+ ' asc work start # 이어갈 세션을 고르거나 계약을 제안받는다',
524
+ ' asc host claude bind <S-ID> --physical ' + id,
525
+ '자동 실행을 원하지 않으면: asc mode manual',
526
+ ].join('\\n'),
527
+ )
528
+ process.exit(2)
529
+ }
530
+ } else if (!isMutation) {
531
+ // 계약 안에서 도는 세션이다. 밖으로 나가는 것은 승인된 실행 경로로만 나간다.
532
+ const forbidden = forbiddenIn(command, FORBIDDEN)
533
+ if (forbidden) {
534
+ console.error(
535
+ \`[ASC guard] '\${forbidden}' 는 AUTO 로 관리되는 세션에서 금지다. \` +
536
+ \`외부 반영은 \\\`asc work publish\\\` 로 나간다 (승인된 Execution Grant).\`,
537
+ )
538
+ process.exit(2) // exit 2 = 도구 실행 차단
539
+ }
540
+ }
541
+ }
542
+
543
+ process.exit(0)
451
544
  ${observerSnippet()}`;
452
545
  }
@@ -36,6 +36,24 @@ export declare function locate(paths: InstallPaths): {
36
36
  manifest: string;
37
37
  hooks: HookSpec[];
38
38
  };
39
+ /**
40
+ * ASC control-plane 을 Host 권한 계층에서 통과시키는 규칙 (E-02, 2층).
41
+ *
42
+ * 0.7.1 실측에서 raw 외부 write 는 ASC Guard 가 막고, 그 자리의 안전한 출구인
43
+ * `asc grant issue` 는 Host 의 권한 판정이 막았다. 막는 길과 나가는 길이 동시에 닫히면
44
+ * 사람이 갇힌다. Host 안에서 우리가 손댈 수 있는 자리는 이 한 줄뿐이다 — 우리 명령을
45
+ * 명시적으로 허용 목록에 올린다. **허용하는 것은 ASC CLI 뿐이고**, 실행 권한이 있는지는
46
+ * 그 다음에 Core 가 판정한다 (Guard allows, then Core decides).
47
+ */
48
+ export declare const CONTROL_PLANE_ALLOW_RULES: readonly string[];
49
+ /** Host 가 ASC control-plane 을 실제로 실행할 수 있는가 (AUTO readiness 의 첫 축). */
50
+ export type ControlPlaneAccess = {
51
+ allowed: boolean;
52
+ /** deny 규칙이 우리 명령을 막고 있는가. 이것이 참이면 AUTO 는 활성화하지 않는다. */
53
+ denied: boolean;
54
+ detail?: string;
55
+ };
56
+ export declare function controlPlaneAccess(paths: InstallPaths): Promise<ControlPlaneAccess>;
39
57
  export type InstallOutcome = {
40
58
  written: string[];
41
59
  skipped: {