The compiler connects them.
When compiling outer and inner, the func name in outer is marked as a closure, and a free variable in inner. On execution, the interpreter then knows to attach a reference to the func name from outer to the inner function object as a closure cell.
You can see the result in the disassembly of the bytecode:
>>> import dis
>>> dis.dis(outer)
2 0 LOAD_CLOSURE 0 (func)
3 BUILD_TUPLE 1
6 LOAD_CONST 1 (<code object inner at 0x1079a5ab0, file "<stdin>", line 2>)
9 MAKE_CLOSURE 0
12 STORE_FAST 1 (inner)
4 15 LOAD_FAST 1 (inner)
18 RETURN_VALUE
>>> inner = outer(lambda a, b: None)
>>> dis.dis(inner)
3 0 LOAD_DEREF 0 (func)
3 LOAD_GLOBAL 0 (arg_inner)
6 LOAD_FAST 0 (foo)
9 CALL_FUNCTION 2
12 POP_TOP
13 LOAD_CONST 0 (None)
16 RETURN_VALUE
The LOAD_CLOSURE wraps the func name in a closure for inner to use; MAKE_CLOSURE builds a function object (from the byte code object loaded with LOAD_CONST) with attached closure cells as a tuple. In inner, the LOAD_DEREF opcode loads the value from the func closure.
The closure can be found back on the resulting inner function object:
>>> inner.func_closure
(<cell at 0x107a25a28: function object at 0x107a28b18>,)
>>> inner.func_code.co_freevars
('func',)
>>> inner.func_closure[0].cell_contents
<function <lambda> at 0x107a28b18>
so the inner function object carries the closure with it for later dereferencing.